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.
- package/README.md +3 -2
- package/dist/authoring/index.d.ts +222 -34
- package/dist/authoring/index.js +11 -1
- package/dist/{chunk-NFLNA2SC.js → chunk-4OZFMCTT.js} +7 -1
- package/dist/{chunk-LWKQDDLZ.js → chunk-A3H32P2J.js} +46 -1
- package/dist/chunk-F7GZYEAC.js +41 -0
- package/dist/chunk-MUFMJRXR.js +308 -0
- package/dist/chunk-S5KFP4JQ.js +20 -0
- package/dist/chunk-Y6LLTN7W.js +99 -0
- package/dist/chunk-YUZ4TXOY.js +27 -0
- package/dist/http/index.d.ts +9 -0
- package/dist/http/index.js +6 -0
- package/dist/index.d.ts +3 -8
- package/dist/index.js +23 -1
- package/dist/manifest/contract.d.ts +8 -0
- package/dist/manifest/index.d.ts +8 -6
- package/dist/manifest/index.js +3 -3
- package/dist/mcp/client.d.ts +9 -0
- package/dist/mcp/index.d.ts +11 -0
- package/dist/mcp/index.js +7 -0
- package/dist/openapi/index.d.ts +14 -0
- package/dist/openapi/index.js +7 -0
- package/dist/openapi/mapper.d.ts +7 -0
- package/dist/runner/index.js +36 -51
- package/dist/runner/protocol.d.ts +20 -15
- package/dist/runner/protocol.js +5 -3
- package/dist/runtime/index.d.ts +83 -0
- package/dist/runtime/index.js +12 -0
- package/dist/workerd/index.d.ts +7 -2
- package/dist/workerd/index.js +7 -3
- package/package.json +18 -7
- package/src/authoring/index.ts +362 -44
- package/src/http/index.ts +26 -0
- package/src/index.ts +3 -8
- package/src/manifest/contract.ts +9 -0
- package/src/manifest/index.ts +89 -157
- package/src/mcp/client.ts +59 -0
- package/src/mcp/index.ts +42 -0
- package/src/openapi/index.ts +70 -0
- package/src/openapi/mapper.ts +25 -0
- package/src/runner/index.ts +50 -87
- package/src/runner/protocol.ts +34 -21
- package/src/runtime/index.ts +550 -0
- package/src/workerd/index.ts +17 -2
- package/dist/chunk-J5AVXTHA.js +0 -154
- package/dist/chunk-RZA7AXBK.js +0 -107
- package/dist/manifest/collect.d.ts +0 -64
- package/src/manifest/collect.ts +0 -198
package/src/runner/protocol.ts
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Data, type Effect, FileSystem } from "effect";
|
|
2
2
|
import type { AppManifestResult, PublishError } from "../manifest/contract";
|
|
3
|
+
import type {
|
|
4
|
+
CallFailureKind,
|
|
5
|
+
DiscoveredTool,
|
|
6
|
+
ToolCtx,
|
|
7
|
+
ToolCtxEntry,
|
|
8
|
+
} from "../runtime/index";
|
|
3
9
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
10
|
+
export type {
|
|
11
|
+
DiscoveredTool,
|
|
12
|
+
ToolCtx,
|
|
13
|
+
ToolCtxEntry,
|
|
14
|
+
ToolDescriptor,
|
|
15
|
+
} from "../runtime/index";
|
|
11
16
|
|
|
12
|
-
/**
|
|
13
|
-
export type
|
|
17
|
+
/** Which invocation phase failed. */
|
|
18
|
+
export type CallErrorKind = CallFailureKind;
|
|
14
19
|
|
|
15
|
-
/** A runner operation failure associated with its target. */
|
|
20
|
+
/** A runner operation failure associated with its target and phase. */
|
|
16
21
|
export class CallError extends Data.TaggedError("CallError")<{
|
|
17
22
|
readonly target: string;
|
|
23
|
+
readonly kind: CallErrorKind;
|
|
18
24
|
readonly reason: string;
|
|
19
25
|
}> {
|
|
20
26
|
override get message(): string {
|
|
@@ -22,20 +28,27 @@ export class CallError extends Data.TaggedError("CallError")<{
|
|
|
22
28
|
}
|
|
23
29
|
}
|
|
24
30
|
|
|
31
|
+
/** A discovery failure associated with one dynamic segment. */
|
|
32
|
+
export class DiscoverError extends Data.TaggedError("DiscoverError")<{
|
|
33
|
+
readonly segment: string;
|
|
34
|
+
readonly reason: string;
|
|
35
|
+
}> {
|
|
36
|
+
override get message(): string {
|
|
37
|
+
return `${this.segment}: ${this.reason}`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
/** The platform protocol implemented by an app execution environment. */
|
|
26
42
|
export interface AppRunner {
|
|
27
|
-
manifest(): Effect.Effect<
|
|
28
|
-
AppManifestResult,
|
|
29
|
-
PublishError,
|
|
30
|
-
Crypto.Crypto | FileSystem.FileSystem
|
|
31
|
-
>;
|
|
43
|
+
manifest(): Effect.Effect<AppManifestResult, PublishError, FileSystem.FileSystem>;
|
|
32
44
|
call(
|
|
33
|
-
|
|
45
|
+
address: string,
|
|
34
46
|
input: unknown,
|
|
35
47
|
ctx: ToolCtx,
|
|
36
|
-
|
|
48
|
+
detail?: unknown,
|
|
49
|
+
): Effect.Effect<unknown, CallError, FileSystem.FileSystem>;
|
|
37
50
|
discover(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
): Effect.Effect<
|
|
51
|
+
segment: string,
|
|
52
|
+
ctx: ToolCtx,
|
|
53
|
+
): Effect.Effect<readonly DiscoveredTool[], DiscoverError, FileSystem.FileSystem>;
|
|
41
54
|
}
|
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The execution core: everything that happens after authored modules
|
|
3
|
+
* load, in any environment. Project a registry into its deterministic
|
|
4
|
+
* manifest, validate a call's input, run the handler, shape the
|
|
5
|
+
* outcome. There is exactly one implementation of this logic; the
|
|
6
|
+
* local runner and the sandboxed workerd guest are both thin doors
|
|
7
|
+
* onto this module.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately dependency-free (no effect, no node builtins): this
|
|
10
|
+
* module bundles into sandboxed guests, where every dependency is
|
|
11
|
+
* paid on each cold start. Digesting uses WebCrypto, which node, bun,
|
|
12
|
+
* and workerd all provide.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
DiscoveredToolSpec,
|
|
17
|
+
DynamicToolsSpec,
|
|
18
|
+
Registry,
|
|
19
|
+
} from "../authoring/index";
|
|
20
|
+
import type {
|
|
21
|
+
AppManifestResult,
|
|
22
|
+
CollectedClaim,
|
|
23
|
+
CollectedRequirement,
|
|
24
|
+
JsonSchemaObject,
|
|
25
|
+
ManifestDynamic,
|
|
26
|
+
ManifestTool,
|
|
27
|
+
} from "../manifest/contract";
|
|
28
|
+
|
|
29
|
+
// ── tool capabilities ─────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
/** The capability exposed to one tool requirement role. */
|
|
32
|
+
export interface ToolCtxEntry {
|
|
33
|
+
readonly grant: string;
|
|
34
|
+
readonly kind: "bearer" | "basic" | "secrets" | "oauth";
|
|
35
|
+
readonly token?: string;
|
|
36
|
+
readonly username?: string;
|
|
37
|
+
readonly password?: string;
|
|
38
|
+
readonly secrets?: Readonly<Record<string, string>>;
|
|
39
|
+
fetch(path: string, init?: RequestInit): Promise<Response>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Tool capabilities keyed by requirement role. */
|
|
43
|
+
export type ToolCtx = Readonly<Record<string, ToolCtxEntry>>;
|
|
44
|
+
|
|
45
|
+
// ── outcomes ──────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/** A manifest projection: the app's deterministic description, or why not. */
|
|
48
|
+
export type ManifestOutcome =
|
|
49
|
+
| { readonly ok: true; readonly result: AppManifestResult }
|
|
50
|
+
| { readonly ok: false; readonly problems: readonly string[] };
|
|
51
|
+
|
|
52
|
+
/** Which invocation phase failed. */
|
|
53
|
+
export type CallFailureKind = "input" | "handler";
|
|
54
|
+
|
|
55
|
+
/** One tool invocation's result envelope. */
|
|
56
|
+
export type CallOutcome =
|
|
57
|
+
| { readonly ok: true; readonly value: unknown }
|
|
58
|
+
| {
|
|
59
|
+
readonly ok: false;
|
|
60
|
+
readonly kind: CallFailureKind;
|
|
61
|
+
readonly reason: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Agent-facing descriptor compiled from one discovery result. */
|
|
65
|
+
export interface ToolDescriptor {
|
|
66
|
+
readonly description: string;
|
|
67
|
+
readonly inputSchema: JsonSchemaObject;
|
|
68
|
+
readonly annotations: {
|
|
69
|
+
readonly readOnly?: boolean;
|
|
70
|
+
readonly destructive?: boolean;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One tool emitted by a dynamic segment discovery. */
|
|
75
|
+
export interface DiscoveredTool {
|
|
76
|
+
readonly segment: string;
|
|
77
|
+
readonly name: string;
|
|
78
|
+
readonly descriptor: ToolDescriptor;
|
|
79
|
+
readonly detail: unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One dynamic discovery result envelope. */
|
|
83
|
+
export type DiscoverOutcome =
|
|
84
|
+
| { readonly ok: true; readonly value: readonly DiscoveredTool[] }
|
|
85
|
+
| { readonly ok: false; readonly reason: string };
|
|
86
|
+
|
|
87
|
+
const failure = (kind: CallFailureKind, reason: string): CallOutcome => ({
|
|
88
|
+
ok: false,
|
|
89
|
+
kind,
|
|
90
|
+
reason,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const errorMessage = (error: unknown): string =>
|
|
94
|
+
error instanceof Error ? error.message : String(error);
|
|
95
|
+
|
|
96
|
+
// ── input schema compilation ──────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
type JsonValue =
|
|
99
|
+
| null
|
|
100
|
+
| boolean
|
|
101
|
+
| number
|
|
102
|
+
| string
|
|
103
|
+
| readonly JsonValue[]
|
|
104
|
+
| { readonly [key: string]: JsonValue };
|
|
105
|
+
|
|
106
|
+
const isJsonValue = (value: unknown): value is JsonValue => {
|
|
107
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
108
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
109
|
+
if (Array.isArray(value)) return value.every(isJsonValue);
|
|
110
|
+
if (typeof value !== "object") return false;
|
|
111
|
+
return Object.values(value).every(isJsonValue);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
interface StandardJsonSchemaCapable {
|
|
115
|
+
readonly "~standard": {
|
|
116
|
+
readonly jsonSchema?: {
|
|
117
|
+
readonly input: (options: { readonly target: string }) => unknown;
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const standardJsonSchemaOf = (input: object) =>
|
|
123
|
+
"~standard" in input
|
|
124
|
+
? (input as StandardJsonSchemaCapable)["~standard"].jsonSchema
|
|
125
|
+
: undefined;
|
|
126
|
+
|
|
127
|
+
/** Strict-by-default: authored schemas reject unknown keys unless stated. */
|
|
128
|
+
const strictify = (node: unknown): unknown => {
|
|
129
|
+
if (typeof node !== "object" || node === null) return node;
|
|
130
|
+
if (Array.isArray(node)) return node.map(strictify);
|
|
131
|
+
const object = { ...(node as Record<string, unknown>) };
|
|
132
|
+
if (object.type === "object" && object.additionalProperties === undefined)
|
|
133
|
+
object.additionalProperties = false;
|
|
134
|
+
for (const key of ["items", "anyOf", "oneOf", "allOf"])
|
|
135
|
+
if (key in object) object[key] = strictify(object[key]);
|
|
136
|
+
for (const key of ["properties", "$defs"])
|
|
137
|
+
if (object[key] !== undefined && typeof object[key] === "object" && object[key] !== null)
|
|
138
|
+
object[key] = Object.fromEntries(
|
|
139
|
+
Object.entries(object[key] as Record<string, unknown>).map(([name, value]) => [
|
|
140
|
+
name,
|
|
141
|
+
strictify(value),
|
|
142
|
+
]),
|
|
143
|
+
);
|
|
144
|
+
return object;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
type InputSchemaCompilation =
|
|
148
|
+
| { readonly value: JsonSchemaObject }
|
|
149
|
+
| { readonly problem: string };
|
|
150
|
+
|
|
151
|
+
export const compileInputSchema = (input: object, where: string): InputSchemaCompilation => {
|
|
152
|
+
const standard = standardJsonSchemaOf(input);
|
|
153
|
+
if ("~standard" in input && typeof standard?.input !== "function")
|
|
154
|
+
return {
|
|
155
|
+
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`,
|
|
156
|
+
};
|
|
157
|
+
try {
|
|
158
|
+
const compiled = strictify(
|
|
159
|
+
typeof standard?.input === "function"
|
|
160
|
+
? standard.input({ target: "draft-2020-12" })
|
|
161
|
+
: structuredClone(input),
|
|
162
|
+
);
|
|
163
|
+
if (
|
|
164
|
+
typeof compiled !== "object" ||
|
|
165
|
+
compiled === null ||
|
|
166
|
+
Array.isArray(compiled) ||
|
|
167
|
+
!isJsonValue(compiled)
|
|
168
|
+
)
|
|
169
|
+
throw new Error("expected a JSON object");
|
|
170
|
+
return { value: compiled as JsonSchemaObject };
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return {
|
|
173
|
+
problem: `${where}: input schema could not compile to JSON schema (${errorMessage(error)})`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// ── registry projection ───────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
const requirementKey = Symbol.for("executor/apps/requirement");
|
|
181
|
+
|
|
182
|
+
interface RequirementSpecShape {
|
|
183
|
+
readonly service: {
|
|
184
|
+
readonly origin: string;
|
|
185
|
+
readonly auth?: Readonly<Record<string, unknown>>;
|
|
186
|
+
};
|
|
187
|
+
readonly arity: "one" | "optional" | "many";
|
|
188
|
+
readonly grant?: string;
|
|
189
|
+
readonly description?: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const requirementSpecOf = (value: unknown): RequirementSpecShape | undefined =>
|
|
193
|
+
typeof value === "object" && value !== null && requirementKey in value
|
|
194
|
+
? ((value as Record<PropertyKey, unknown>)[requirementKey] as RequirementSpecShape)
|
|
195
|
+
: undefined;
|
|
196
|
+
|
|
197
|
+
const segmentOf = (file: string): string => {
|
|
198
|
+
const name = file.slice(file.lastIndexOf("/") + 1);
|
|
199
|
+
return name.endsWith(".ts") ? name.slice(0, -3) : name;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const canonicalJson = (value: unknown): string => {
|
|
203
|
+
const sortKeys = (node: unknown): unknown => {
|
|
204
|
+
if (Array.isArray(node)) return node.map(sortKeys);
|
|
205
|
+
if (typeof node === "object" && node !== null)
|
|
206
|
+
return Object.fromEntries(
|
|
207
|
+
Object.entries(node as Record<string, unknown>)
|
|
208
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
209
|
+
.map(([key, child]) => [key, sortKeys(child)]),
|
|
210
|
+
);
|
|
211
|
+
return node;
|
|
212
|
+
};
|
|
213
|
+
return JSON.stringify(sortKeys(value));
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
interface CollectedManifest {
|
|
217
|
+
readonly app: { readonly key: string; readonly label: string; readonly description: string };
|
|
218
|
+
readonly services: readonly CollectedClaim[];
|
|
219
|
+
readonly tools: readonly ManifestTool[];
|
|
220
|
+
readonly dynamic: readonly ManifestDynamic[];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
type RegistryCollection =
|
|
224
|
+
| { readonly problems: readonly string[] }
|
|
225
|
+
| {
|
|
226
|
+
readonly problems: readonly [];
|
|
227
|
+
readonly manifest: CollectedManifest;
|
|
228
|
+
readonly canonical: string;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const collectRegistry = (registry: Registry): RegistryCollection => {
|
|
232
|
+
const problems = registry.problems.map((problem) => problem.message);
|
|
233
|
+
const app = registry.app;
|
|
234
|
+
|
|
235
|
+
const claims = new Map<string, CollectedClaim>();
|
|
236
|
+
for (const [origin, registered] of registry.services) {
|
|
237
|
+
if (registered.file !== "app.ts") continue;
|
|
238
|
+
const { decl } = registered;
|
|
239
|
+
claims.set(origin, {
|
|
240
|
+
origin,
|
|
241
|
+
reach: decl.reach ?? [],
|
|
242
|
+
params: decl.params ?? {},
|
|
243
|
+
auth: decl.auth ?? {},
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const collectRequirement = (
|
|
248
|
+
role: string,
|
|
249
|
+
requirement: unknown,
|
|
250
|
+
where: string,
|
|
251
|
+
): CollectedRequirement | undefined => {
|
|
252
|
+
const spec = requirementSpecOf(requirement);
|
|
253
|
+
if (spec === undefined) {
|
|
254
|
+
problems.push(`${where}: requirement '${role}' is not a service/requirement value`);
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
const origin = spec.service.origin;
|
|
258
|
+
if (!claims.has(origin))
|
|
259
|
+
problems.push(`${where}: requirement '${role}' names ${origin}, which app.ts does not declare`);
|
|
260
|
+
if (spec.grant !== undefined && !(spec.grant in (spec.service.auth ?? {})))
|
|
261
|
+
problems.push(
|
|
262
|
+
`${where}: requirement '${role}' pins grant '${spec.grant}', not a auth method on ${origin}`,
|
|
263
|
+
);
|
|
264
|
+
return {
|
|
265
|
+
role,
|
|
266
|
+
service: origin,
|
|
267
|
+
arity: spec.arity,
|
|
268
|
+
...(spec.grant === undefined ? {} : { grant: spec.grant }),
|
|
269
|
+
...(spec.description === undefined ? {} : { description: spec.description }),
|
|
270
|
+
};
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
if (app === undefined) problems.push("app.ts must call defineApp({...})");
|
|
274
|
+
else if (typeof app.description !== "string" || app.description.trim().length === 0)
|
|
275
|
+
problems.push("app.ts: app description must not be empty");
|
|
276
|
+
|
|
277
|
+
for (const [file, summary] of registry.toolFiles) {
|
|
278
|
+
if (summary.definitions > 1 && summary.hasUnnamedTool)
|
|
279
|
+
problems.push(`${file}: multi-tool files must name every tool`);
|
|
280
|
+
if (registry.dynamic.has(file))
|
|
281
|
+
problems.push(`${file}: a file claims a dynamic tool set or defines tools, never both`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const tools: ManifestTool[] = [];
|
|
285
|
+
if (app !== undefined) {
|
|
286
|
+
for (const [name, registered] of registry.tools) {
|
|
287
|
+
const { spec, file } = registered;
|
|
288
|
+
const address = `${app.key}/${name}`;
|
|
289
|
+
if (typeof spec.description !== "string" || spec.description.trim().length === 0)
|
|
290
|
+
problems.push(`${file}: tool '${name}' description must not be empty`);
|
|
291
|
+
const requires = Object.entries(spec.requirements ?? {})
|
|
292
|
+
.flatMap(([role, requirement]) => {
|
|
293
|
+
const collected = collectRequirement(role, requirement, address);
|
|
294
|
+
return collected === undefined ? [] : [collected];
|
|
295
|
+
})
|
|
296
|
+
.sort((left, right) => left.role.localeCompare(right.role));
|
|
297
|
+
const compiled = compileInputSchema(spec.input, address);
|
|
298
|
+
if ("problem" in compiled) {
|
|
299
|
+
problems.push(compiled.problem);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
tools.push({
|
|
303
|
+
address,
|
|
304
|
+
name,
|
|
305
|
+
description: spec.description,
|
|
306
|
+
inputSchema: compiled.value,
|
|
307
|
+
annotations: spec.annotations ?? {},
|
|
308
|
+
requires,
|
|
309
|
+
file,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const dynamic: ManifestDynamic[] = [];
|
|
315
|
+
const seenSegments = new Map<string, string>();
|
|
316
|
+
for (const [file, registered] of registry.dynamic) {
|
|
317
|
+
const segment = segmentOf(file);
|
|
318
|
+
const where = `${file}: dynamic tool set '${segment}'`;
|
|
319
|
+
const existing = seenSegments.get(segment);
|
|
320
|
+
if (existing !== undefined) {
|
|
321
|
+
problems.push(`${where}: segment already claimed by ${existing}`);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
seenSegments.set(segment, file);
|
|
325
|
+
|
|
326
|
+
const requires = Object.entries(registered.spec.requirements ?? {})
|
|
327
|
+
.flatMap(([role, requirement]) => {
|
|
328
|
+
const collected = collectRequirement(role, requirement, where);
|
|
329
|
+
return collected === undefined ? [] : [collected];
|
|
330
|
+
})
|
|
331
|
+
.sort((left, right) => left.role.localeCompare(right.role));
|
|
332
|
+
dynamic.push({
|
|
333
|
+
segment,
|
|
334
|
+
description: registered.spec.description,
|
|
335
|
+
requires,
|
|
336
|
+
file,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (problems.length > 0 || app === undefined) return { problems };
|
|
341
|
+
const services = [...claims.values()].sort((left, right) =>
|
|
342
|
+
left.origin.localeCompare(right.origin),
|
|
343
|
+
);
|
|
344
|
+
const sortedTools = [...tools].sort((left, right) => left.address.localeCompare(right.address));
|
|
345
|
+
const sortedDynamic = [...dynamic].sort((left, right) =>
|
|
346
|
+
left.segment.localeCompare(right.segment),
|
|
347
|
+
);
|
|
348
|
+
const appResult = { key: app.key, label: app.label, description: app.description };
|
|
349
|
+
const manifest = {
|
|
350
|
+
app: appResult,
|
|
351
|
+
services,
|
|
352
|
+
tools: sortedTools,
|
|
353
|
+
dynamic: sortedDynamic,
|
|
354
|
+
} satisfies CollectedManifest;
|
|
355
|
+
return { problems: [], manifest, canonical: canonicalJson(manifest) };
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const digestOf = async (canonical: string): Promise<string> => {
|
|
359
|
+
const bytes = new Uint8Array(
|
|
360
|
+
await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical)),
|
|
361
|
+
);
|
|
362
|
+
return Array.from(bytes.slice(0, 8), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
/** Project a loaded registry into the app's deterministic manifest. */
|
|
366
|
+
export const manifestOf = async (registry: Registry): Promise<ManifestOutcome> => {
|
|
367
|
+
const collected = collectRegistry(registry);
|
|
368
|
+
if (collected.problems.length > 0) return { ok: false, problems: collected.problems };
|
|
369
|
+
if (!("manifest" in collected))
|
|
370
|
+
return { ok: false, problems: ["validated registry projection unexpectedly missing"] };
|
|
371
|
+
return {
|
|
372
|
+
ok: true,
|
|
373
|
+
result: { digest: `snap:${await digestOf(collected.canonical)}`, ...collected.manifest },
|
|
374
|
+
};
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// ── invocation ────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
interface StandardValidateCapable {
|
|
380
|
+
readonly "~standard": {
|
|
381
|
+
readonly validate: (value: unknown) =>
|
|
382
|
+
| { readonly value: unknown; readonly issues?: undefined }
|
|
383
|
+
| { readonly issues: ReadonlyArray<{ readonly message: string }> }
|
|
384
|
+
| Promise<
|
|
385
|
+
| { readonly value: unknown; readonly issues?: undefined }
|
|
386
|
+
| { readonly issues: ReadonlyArray<{ readonly message: string }> }
|
|
387
|
+
>;
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const standardValidateOf = (input: object) =>
|
|
392
|
+
"~standard" in input &&
|
|
393
|
+
typeof (input as StandardValidateCapable)["~standard"].validate === "function"
|
|
394
|
+
? (input as StandardValidateCapable)["~standard"].validate
|
|
395
|
+
: undefined;
|
|
396
|
+
|
|
397
|
+
type ParsedInput =
|
|
398
|
+
| { readonly ok: true; readonly value: unknown }
|
|
399
|
+
| { readonly ok: false; readonly reason: string };
|
|
400
|
+
|
|
401
|
+
const parseInput = async (schema: object, input: unknown): Promise<ParsedInput> => {
|
|
402
|
+
const validate = standardValidateOf(schema);
|
|
403
|
+
if (validate === undefined)
|
|
404
|
+
return { ok: false, reason: "input schema does not implement Standard Schema validate" };
|
|
405
|
+
try {
|
|
406
|
+
const result = await validate(input);
|
|
407
|
+
if (result.issues !== undefined)
|
|
408
|
+
return {
|
|
409
|
+
ok: false,
|
|
410
|
+
reason: `invalid input (${result.issues.map((issue) => issue.message).join("; ")})`,
|
|
411
|
+
};
|
|
412
|
+
return { ok: true, value: result.value };
|
|
413
|
+
} catch (error) {
|
|
414
|
+
return { ok: false, reason: `input validation threw (${errorMessage(error)})` };
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const splitDynamicAddress = (
|
|
419
|
+
address: string,
|
|
420
|
+
): { readonly segment: string; readonly name: string } | undefined => {
|
|
421
|
+
const slash = address.indexOf("/");
|
|
422
|
+
if (slash <= 0 || slash === address.length - 1) return undefined;
|
|
423
|
+
return { segment: address.slice(0, slash), name: address.slice(slash + 1) };
|
|
424
|
+
};
|
|
425
|
+
|
|
426
|
+
const dynamicFor = (
|
|
427
|
+
registry: Registry,
|
|
428
|
+
segment: string,
|
|
429
|
+
): DynamicToolsSpec | undefined => {
|
|
430
|
+
for (const [file, registered] of registry.dynamic)
|
|
431
|
+
if (segmentOf(file) === segment) return registered.spec;
|
|
432
|
+
return undefined;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
/** Live schemas from the latest successful discovery, retained per loaded registry. */
|
|
436
|
+
const discoveredSpecs = new WeakMap<
|
|
437
|
+
Registry,
|
|
438
|
+
Map<string, ReadonlyMap<string, DiscoveredToolSpec>>
|
|
439
|
+
>();
|
|
440
|
+
|
|
441
|
+
/** Validate input and run one static or dynamic tool handler. */
|
|
442
|
+
export const callTool = async (
|
|
443
|
+
registry: Registry,
|
|
444
|
+
address: string,
|
|
445
|
+
input: unknown,
|
|
446
|
+
ctx: ToolCtx,
|
|
447
|
+
detail?: unknown,
|
|
448
|
+
): Promise<CallOutcome> => {
|
|
449
|
+
const dynamic = splitDynamicAddress(address);
|
|
450
|
+
if (dynamic === undefined) {
|
|
451
|
+
if (detail !== undefined)
|
|
452
|
+
return failure("handler", "a static tool takes no discovery detail");
|
|
453
|
+
const registered = registry.tools.get(address);
|
|
454
|
+
if (registered === undefined) return failure("handler", "unknown tool");
|
|
455
|
+
const parsed = await parseInput(registered.spec.input, input);
|
|
456
|
+
if (!parsed.ok) return failure("input", parsed.reason);
|
|
457
|
+
try {
|
|
458
|
+
// SAFETY: ToolSpec intentionally erases authored handler inputs to keep
|
|
459
|
+
// authoring generic-free; validation and ToolCtx establish this call boundary.
|
|
460
|
+
const handler = registered.spec.handler as (
|
|
461
|
+
value: unknown,
|
|
462
|
+
toolCtx: ToolCtx,
|
|
463
|
+
) => Promise<unknown>;
|
|
464
|
+
return { ok: true, value: await handler(parsed.value, ctx) };
|
|
465
|
+
} catch (error) {
|
|
466
|
+
return failure("handler", `handler failed (${errorMessage(error)})`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (detail === undefined)
|
|
471
|
+
return failure("handler", "a dynamic tool requires discovery detail");
|
|
472
|
+
const pair = dynamicFor(registry, dynamic.segment);
|
|
473
|
+
if (pair === undefined)
|
|
474
|
+
return failure("handler", `unknown dynamic tool set '${dynamic.segment}'`);
|
|
475
|
+
const discovered = discoveredSpecs.get(registry)?.get(dynamic.segment)?.get(dynamic.name);
|
|
476
|
+
if (discovered === undefined)
|
|
477
|
+
return failure(
|
|
478
|
+
"input",
|
|
479
|
+
`dynamic tool '${dynamic.name}' has no compiled discovery descriptor`,
|
|
480
|
+
);
|
|
481
|
+
const parsed = await parseInput(discovered.input, input);
|
|
482
|
+
if (!parsed.ok) return failure("input", parsed.reason);
|
|
483
|
+
try {
|
|
484
|
+
const call = pair.call as (
|
|
485
|
+
tool: { readonly name: string; readonly detail: unknown },
|
|
486
|
+
value: unknown,
|
|
487
|
+
toolCtx: ToolCtx,
|
|
488
|
+
) => Promise<unknown>;
|
|
489
|
+
return {
|
|
490
|
+
ok: true,
|
|
491
|
+
value: await call({ name: dynamic.name, detail }, parsed.value, ctx),
|
|
492
|
+
};
|
|
493
|
+
} catch (error) {
|
|
494
|
+
return failure("handler", `dynamic call failed (${errorMessage(error)})`);
|
|
495
|
+
}
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
/** Discover and compile one dynamic segment. */
|
|
499
|
+
export const discoverTool = async (
|
|
500
|
+
registry: Registry,
|
|
501
|
+
segment: string,
|
|
502
|
+
ctx: ToolCtx,
|
|
503
|
+
): Promise<DiscoverOutcome> => {
|
|
504
|
+
const pair = dynamicFor(registry, segment);
|
|
505
|
+
if (pair === undefined)
|
|
506
|
+
return { ok: false, reason: `unknown dynamic tool set '${segment}'` };
|
|
507
|
+
discoveredSpecs.get(registry)?.delete(segment);
|
|
508
|
+
|
|
509
|
+
let emitted: readonly DiscoveredToolSpec[];
|
|
510
|
+
try {
|
|
511
|
+
const discover = pair.discover as (
|
|
512
|
+
toolCtx: ToolCtx,
|
|
513
|
+
) => Promise<readonly DiscoveredToolSpec[]>;
|
|
514
|
+
emitted = await discover(ctx);
|
|
515
|
+
} catch (error) {
|
|
516
|
+
return { ok: false, reason: `discover failed (${errorMessage(error)})` };
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const tools: DiscoveredTool[] = [];
|
|
520
|
+
const live = new Map<string, DiscoveredToolSpec>();
|
|
521
|
+
for (const tool of emitted) {
|
|
522
|
+
if (live.has(tool.name))
|
|
523
|
+
return { ok: false, reason: `discover emitted duplicate tool '${tool.name}'` };
|
|
524
|
+
const compiled = compileInputSchema(tool.input, `${segment}/${tool.name}`);
|
|
525
|
+
if ("problem" in compiled) return { ok: false, reason: compiled.problem };
|
|
526
|
+
live.set(tool.name, tool);
|
|
527
|
+
tools.push({
|
|
528
|
+
segment,
|
|
529
|
+
name: tool.name,
|
|
530
|
+
descriptor: {
|
|
531
|
+
description: tool.description,
|
|
532
|
+
inputSchema: compiled.value,
|
|
533
|
+
annotations: {
|
|
534
|
+
...(tool.annotations?.readOnly === undefined
|
|
535
|
+
? {}
|
|
536
|
+
: { readOnly: tool.annotations.readOnly }),
|
|
537
|
+
...(tool.annotations?.destructive === undefined
|
|
538
|
+
? {}
|
|
539
|
+
: { destructive: tool.annotations.destructive }),
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
detail: tool.detail ?? null,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const registryDiscoveries = discoveredSpecs.get(registry) ?? new Map();
|
|
547
|
+
registryDiscoveries.set(segment, live);
|
|
548
|
+
discoveredSpecs.set(registry, registryDiscoveries);
|
|
549
|
+
return { ok: true, value: tools.sort((left, right) => left.name.localeCompare(right.name)) };
|
|
550
|
+
};
|
package/src/workerd/index.ts
CHANGED
|
@@ -1,2 +1,17 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The execution core re-exported for sandboxed guests. A generated
|
|
3
|
+
* guest module imports the authored files (filling the registry) and
|
|
4
|
+
* exposes these verbs over RPC; the logic itself lives in ../runtime
|
|
5
|
+
* and is identical to what the local runner executes. @internal
|
|
6
|
+
*/
|
|
7
|
+
export {
|
|
8
|
+
callTool,
|
|
9
|
+
discoverTool,
|
|
10
|
+
manifestOf,
|
|
11
|
+
type CallOutcome,
|
|
12
|
+
type DiscoverOutcome,
|
|
13
|
+
type DiscoveredTool,
|
|
14
|
+
type ManifestOutcome,
|
|
15
|
+
type ToolCtx,
|
|
16
|
+
type ToolCtxEntry,
|
|
17
|
+
} from "../runtime/index";
|