apps 0.8.3 → 1.0.0-beta.0
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 +51 -166
- package/dist/authoring/index.d.ts +205 -0
- package/dist/authoring/index.js +26 -0
- package/dist/index-8wmxmfhc.js +90 -0
- package/dist/index-dh4z81es.js +10 -0
- package/dist/index-mcjwntr2.js +13 -0
- package/dist/index-wfngzvv2.js +228 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +26 -0
- package/dist/manifest/collect.d.ts +64 -0
- package/dist/manifest/contract.d.ts +55 -0
- package/dist/manifest/contract.js +8 -0
- package/dist/manifest/index.d.ts +18 -0
- package/dist/manifest/index.js +15 -0
- package/dist/runner/index.d.ts +4 -0
- package/dist/runner/index.js +61 -0
- package/dist/runner/protocol.d.ts +28 -0
- package/dist/runner/protocol.js +6 -0
- package/dist/workerd/index.d.ts +2 -0
- package/dist/workerd/index.js +3 -0
- package/package.json +49 -36
- package/src/authoring/index.ts +340 -0
- package/src/index.ts +9 -0
- package/src/manifest/collect.ts +198 -0
- package/src/manifest/contract.ts +55 -0
- package/src/manifest/index.ts +224 -0
- package/src/runner/index.ts +117 -0
- package/src/runner/protocol.ts +41 -0
- package/src/workerd/index.ts +2 -0
- package/.npmignore +0 -1
- package/bin/apps +0 -36
- package/bin/build +0 -14
- package/bin/doc +0 -16
- package/dist/app.js +0 -9773
- package/dist/app.min.js +0 -5
- package/dist/schema.html +0 -119
- package/dist/schema.md +0 -156
- package/index.html +0 -6
- package/index.js +0 -115
- package/lib/addons.js +0 -67
- package/schema.js +0 -100
- package/templates/app.mustache.html +0 -36
- package/templates/build.mustache.html +0 -13
- package/templates/schema.mustache.html +0 -20
- package/test/addons.js +0 -172
- package/test/cli.js +0 -53
- package/test/fixtures/app.json +0 -32
- package/test/index.js +0 -277
- package/test/mocha.opts +0 -2
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronous authoring primitives imported by app.ts and tools/*.ts.
|
|
3
|
+
* Authored modules record plain claims without I/O or user context; the
|
|
4
|
+
* manifest loader owns execution, validation, and effects.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// ── auth options (auth methods) ──────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
export interface SecretsMethod<
|
|
10
|
+
F extends Readonly<Record<string, SecretField>> = Readonly<Record<string, SecretField>>,
|
|
11
|
+
> {
|
|
12
|
+
readonly kind: "secrets";
|
|
13
|
+
readonly label: string;
|
|
14
|
+
/** Markdown shown under this auth method on the connect page, for example:
|
|
15
|
+
* "Create a fine-grained token at github.com/settings/tokens." */
|
|
16
|
+
readonly help?: string;
|
|
17
|
+
readonly fields: F;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SecretField {
|
|
21
|
+
readonly label: string;
|
|
22
|
+
/** Form masking. Stated, never guessed from the field name. */
|
|
23
|
+
readonly secret: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* An oauth auth method is a secrets-shaped auth method with acquisition strategies
|
|
28
|
+
* layered on: the material contract is always {token, refresh_token?,
|
|
29
|
+
* expires_at?}, and the host fills it by the best strategy it can offer
|
|
30
|
+
* — authorization-code ceremony, device-code ceremony, or paste. Paste
|
|
31
|
+
* is the always-on floor (a pasted token spends identically), so the
|
|
32
|
+
* auth method works on any deployment; ceremony is progressive enhancement.
|
|
33
|
+
* Handlers cannot tell how material arrived.
|
|
34
|
+
*/
|
|
35
|
+
export interface OAuthMethod {
|
|
36
|
+
readonly kind: "oauth";
|
|
37
|
+
readonly label: string;
|
|
38
|
+
/** Markdown shown under this auth method on the connect page, for example:
|
|
39
|
+
* "Create a fine-grained token at github.com/settings/tokens." */
|
|
40
|
+
readonly help?: string;
|
|
41
|
+
/** Endpoints only when the service publishes no well-knowns —
|
|
42
|
+
* discovery (RFC 9728 + AS metadata) outranks these when present.
|
|
43
|
+
* Authors state facts about the service, never about the client. */
|
|
44
|
+
readonly authorizationEndpoint?: string;
|
|
45
|
+
readonly tokenEndpoint?: string;
|
|
46
|
+
readonly scopes?: readonly string[];
|
|
47
|
+
/** Set false only when a user-pasted token genuinely cannot work
|
|
48
|
+
* (audience-bound tokens, DPoP). Stated, never inferred. */
|
|
49
|
+
readonly paste?: false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type AuthMethod = SecretsMethod | OAuthMethod;
|
|
53
|
+
|
|
54
|
+
export const secrets = <const F extends Readonly<Record<string, SecretField>>>(opts: {
|
|
55
|
+
readonly label: string;
|
|
56
|
+
/** Markdown shown under this auth method on the connect page, for example:
|
|
57
|
+
* "Create a fine-grained token at github.com/settings/tokens." */
|
|
58
|
+
readonly help?: string;
|
|
59
|
+
readonly fields: F;
|
|
60
|
+
}): SecretsMethod<F> => ({ kind: "secrets", ...opts });
|
|
61
|
+
|
|
62
|
+
export const oauth = (opts: {
|
|
63
|
+
readonly label: string;
|
|
64
|
+
/** Markdown shown under this auth method on the connect page, for example:
|
|
65
|
+
* "Create a fine-grained token at github.com/settings/tokens." */
|
|
66
|
+
readonly help?: string;
|
|
67
|
+
readonly authorizationEndpoint?: string;
|
|
68
|
+
readonly tokenEndpoint?: string;
|
|
69
|
+
readonly scopes?: readonly string[];
|
|
70
|
+
readonly paste?: false;
|
|
71
|
+
}): OAuthMethod => ({ kind: "oauth", ...opts });
|
|
72
|
+
|
|
73
|
+
// ── services ──────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export interface ServiceDecl {
|
|
76
|
+
/** Canonical origin, possibly templated: "https://{region}.posthog.com". */
|
|
77
|
+
readonly origin: string;
|
|
78
|
+
/** Hosts beyond the origin the credential legitimately travels to. */
|
|
79
|
+
readonly reach?: readonly string[];
|
|
80
|
+
/** Connection-scoped non-secret connect-form values; keys may fill
|
|
81
|
+
* origin templates. */
|
|
82
|
+
readonly params?: Readonly<Record<string, ParamSpec>>;
|
|
83
|
+
/** Named auth methods. Key = the grant name tools switch on. */
|
|
84
|
+
readonly auth?: Readonly<Record<string, AuthMethod>>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface ParamSpec {
|
|
88
|
+
readonly label: string;
|
|
89
|
+
readonly options?: readonly string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** What the manifest pipeline reads off a requirement value. */
|
|
93
|
+
export interface RequirementSpec {
|
|
94
|
+
readonly service: ServiceDecl;
|
|
95
|
+
readonly arity: "one" | "optional" | "many";
|
|
96
|
+
readonly grant?: string;
|
|
97
|
+
readonly description?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const RequirementKey: unique symbol = Symbol.for("executor/apps/requirement");
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A requirement value in a tool's `requirements` record. A Service IS
|
|
104
|
+
* a requirement (bare value = arity "one"); .optional()/.array()
|
|
105
|
+
* return terminal Requirements so nonsense chains do not typecheck.
|
|
106
|
+
*/
|
|
107
|
+
export interface Requirement<D extends ServiceDecl = ServiceDecl> {
|
|
108
|
+
readonly [RequirementKey]: RequirementSpec;
|
|
109
|
+
describe(text: string): Requirement<D>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface Service<D extends ServiceDecl = ServiceDecl> extends Requirement<D> {
|
|
113
|
+
readonly decl: D;
|
|
114
|
+
optional(): Requirement<D>;
|
|
115
|
+
array(): Requirement<D>;
|
|
116
|
+
/** Pin one auth method: this tool supports only the named grant. */
|
|
117
|
+
grant(name: keyof NonNullable<D["auth"]> & string): Service<D>;
|
|
118
|
+
describe(text: string): Service<D>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A publish-time authoring problem accumulated without interrupting defines. */
|
|
122
|
+
export interface Problem {
|
|
123
|
+
readonly message: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── dynamic tool sets ─────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A dynamic tool-set claim: tools discovered from a live surface (an
|
|
130
|
+
* MCP server's tool list, an OpenAPI document) rather than authored one
|
|
131
|
+
* file at a time. The claim is recorded at publish time; discovery runs
|
|
132
|
+
* later, per connection — an app with zero static tools and one claim
|
|
133
|
+
* saves green, and listing never blocks on an upstream.
|
|
134
|
+
*/
|
|
135
|
+
export type DynamicClaimSpec =
|
|
136
|
+
| {
|
|
137
|
+
readonly kind: "mcp";
|
|
138
|
+
readonly requirement: RequirementSpec;
|
|
139
|
+
/** MCP endpoint URL. Absent = the service origin at its root path. */
|
|
140
|
+
readonly endpoint?: string;
|
|
141
|
+
}
|
|
142
|
+
| {
|
|
143
|
+
readonly kind: "openapi";
|
|
144
|
+
readonly requirement: RequirementSpec;
|
|
145
|
+
/** OpenAPI document: an absolute URL or an app-relative path. */
|
|
146
|
+
readonly spec: string;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const DynamicKey: unique symbol = Symbol.for("executor/apps/dynamic");
|
|
150
|
+
|
|
151
|
+
/** The value fromMCP/fromOpenAPI return: an opaque claim handle a tool
|
|
152
|
+
* file default-exports. The spec is undefined when the claim was
|
|
153
|
+
* invalid (a problem was recorded instead). */
|
|
154
|
+
export interface DynamicToolSet {
|
|
155
|
+
readonly [DynamicKey]: DynamicClaimSpec | undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A registered dynamic claim and its source attribution. @internal */
|
|
159
|
+
export interface RegisteredDynamic {
|
|
160
|
+
readonly spec: DynamicClaimSpec;
|
|
161
|
+
readonly file: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A registered service declaration and its source attribution. @internal */
|
|
165
|
+
export interface RegisteredService {
|
|
166
|
+
readonly decl: ServiceDecl;
|
|
167
|
+
readonly file: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A registered tool declaration and its source attribution. @internal */
|
|
171
|
+
export interface RegisteredTool {
|
|
172
|
+
readonly spec: ToolSpec;
|
|
173
|
+
readonly file: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Mutable claim registry populated while authored modules execute. @internal */
|
|
177
|
+
export interface Registry {
|
|
178
|
+
app: AppManifest | undefined;
|
|
179
|
+
readonly services: Map<string, RegisteredService>;
|
|
180
|
+
readonly tools: Map<string, RegisteredTool>;
|
|
181
|
+
readonly dynamic: Map<string, RegisteredDynamic>;
|
|
182
|
+
readonly problems: Problem[];
|
|
183
|
+
readonly toolFiles: Map<
|
|
184
|
+
string,
|
|
185
|
+
{ definitions: number; hasUnnamedTool: boolean }
|
|
186
|
+
>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Creates an isolated empty authoring registry. @internal */
|
|
190
|
+
export const newRegistry = (): Registry => ({
|
|
191
|
+
app: undefined,
|
|
192
|
+
services: new Map(),
|
|
193
|
+
tools: new Map(),
|
|
194
|
+
dynamic: new Map(),
|
|
195
|
+
problems: [],
|
|
196
|
+
toolFiles: new Map(),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
let activeRegistry = newRegistry();
|
|
200
|
+
let currentFile: string | undefined;
|
|
201
|
+
|
|
202
|
+
/** Swaps the registry receiving subsequent authoring claims. @internal */
|
|
203
|
+
export const setActiveRegistry = (registry: Registry): Registry => {
|
|
204
|
+
const previous = activeRegistry;
|
|
205
|
+
activeRegistry = registry;
|
|
206
|
+
return previous;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** Sets source-file attribution for subsequent authoring claims. @internal */
|
|
210
|
+
export const __enterFile = (path: string): void => {
|
|
211
|
+
currentFile = path;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/** Clears source-file attribution after an authored module import. @internal */
|
|
215
|
+
export const __leaveFile = (): void => {
|
|
216
|
+
currentFile = undefined;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const sourceFileFor = (claim: string): string => {
|
|
220
|
+
if (currentFile !== undefined) return currentFile;
|
|
221
|
+
activeRegistry.problems.push({
|
|
222
|
+
message: `${claim} called outside an authored module import`,
|
|
223
|
+
});
|
|
224
|
+
return "<unattributed>";
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const makeTerminal = <D extends ServiceDecl>(spec: RequirementSpec): Requirement<D> => ({
|
|
228
|
+
[RequirementKey]: spec,
|
|
229
|
+
describe: (text) => makeTerminal<D>({ ...spec, description: text }),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
export const service = <const D extends ServiceDecl>(decl: D): Service<D> => {
|
|
233
|
+
const file = sourceFileFor("service(...)");
|
|
234
|
+
const make = (spec: RequirementSpec): Service<D> => ({
|
|
235
|
+
[RequirementKey]: spec,
|
|
236
|
+
decl,
|
|
237
|
+
optional: () => makeTerminal<D>({ ...spec, arity: "optional" }),
|
|
238
|
+
array: () => makeTerminal<D>({ ...spec, arity: "many" }),
|
|
239
|
+
grant: (name) => make({ ...spec, grant: name }),
|
|
240
|
+
describe: (text) => make({ ...spec, description: text }),
|
|
241
|
+
});
|
|
242
|
+
const value = make({ service: decl, arity: "one" });
|
|
243
|
+
if (!activeRegistry.services.has(decl.origin))
|
|
244
|
+
activeRegistry.services.set(decl.origin, { decl, file });
|
|
245
|
+
return value;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const isRequirement = (value: unknown): value is Requirement =>
|
|
249
|
+
typeof value === "object" && value !== null && RequirementKey in value;
|
|
250
|
+
|
|
251
|
+
const isService = (value: unknown): value is Service =>
|
|
252
|
+
isRequirement(value) &&
|
|
253
|
+
"decl" in value &&
|
|
254
|
+
"optional" in value &&
|
|
255
|
+
"array" in value &&
|
|
256
|
+
"grant" in value;
|
|
257
|
+
|
|
258
|
+
export const requirementSpecOf = (value: unknown): RequirementSpec | undefined =>
|
|
259
|
+
isService(value) || isRequirement(value) ? value[RequirementKey] : undefined;
|
|
260
|
+
|
|
261
|
+
// ── apps ──────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
export interface AppManifest {
|
|
264
|
+
readonly kind: "executor-app";
|
|
265
|
+
readonly key: string;
|
|
266
|
+
readonly label: string;
|
|
267
|
+
readonly description: string;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export const defineApp = (manifest: {
|
|
271
|
+
readonly key: string;
|
|
272
|
+
readonly label: string;
|
|
273
|
+
readonly description: string;
|
|
274
|
+
}): AppManifest => {
|
|
275
|
+
const file = sourceFileFor("defineApp(...)");
|
|
276
|
+
const value: AppManifest = { kind: "executor-app", ...manifest };
|
|
277
|
+
if (activeRegistry.app === undefined) activeRegistry.app = value;
|
|
278
|
+
else activeRegistry.problems.push({ message: `${file}: duplicate defineApp(...)` });
|
|
279
|
+
return value;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// ── tools ─────────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
/** Any Standard Schema (zod preferred) or a raw JSON-schema object. */
|
|
285
|
+
export type SchemaLike = object;
|
|
286
|
+
|
|
287
|
+
export interface ToolAnnotations {
|
|
288
|
+
readonly readOnly?: boolean;
|
|
289
|
+
readonly destructive?: boolean;
|
|
290
|
+
/** Facts, not flow: shortfall surfaces as the scope challenge. */
|
|
291
|
+
readonly scopes?: readonly string[];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export interface ToolSpec {
|
|
295
|
+
/** Address segment. Single-tool files may omit it (filename). */
|
|
296
|
+
readonly name?: string;
|
|
297
|
+
readonly description: string;
|
|
298
|
+
readonly requirements?: Readonly<Record<string, Requirement>>;
|
|
299
|
+
readonly input: SchemaLike;
|
|
300
|
+
readonly output?: SchemaLike;
|
|
301
|
+
readonly annotations?: ToolAnnotations;
|
|
302
|
+
handler(input: never, ctx: never): Promise<unknown>;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const ToolKey: unique symbol = Symbol.for("executor/apps/tool");
|
|
306
|
+
|
|
307
|
+
export interface Tool {
|
|
308
|
+
readonly [ToolKey]: ToolSpec;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export const defineTool = (spec: ToolSpec): Tool => {
|
|
312
|
+
const file = sourceFileFor("defineTool(...)");
|
|
313
|
+
const value: Tool = { [ToolKey]: spec };
|
|
314
|
+
const fileName = file.slice(file.lastIndexOf("/") + 1);
|
|
315
|
+
const stem = fileName.endsWith(".ts") ? fileName.slice(0, -3) : fileName;
|
|
316
|
+
const name = spec.name ?? stem;
|
|
317
|
+
const fileSummary = activeRegistry.toolFiles.get(file);
|
|
318
|
+
activeRegistry.toolFiles.set(file, {
|
|
319
|
+
definitions: (fileSummary?.definitions ?? 0) + 1,
|
|
320
|
+
hasUnnamedTool: fileSummary?.hasUnnamedTool === true || spec.name === undefined,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const existing = activeRegistry.tools.get(name);
|
|
324
|
+
if (existing !== undefined) {
|
|
325
|
+
activeRegistry.problems.push({
|
|
326
|
+
message: `${file}: tool name '${name}' is already defined in ${existing.file}`,
|
|
327
|
+
});
|
|
328
|
+
} else activeRegistry.tools.set(name, { spec, file });
|
|
329
|
+
|
|
330
|
+
return value;
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const isTool = (value: unknown): value is Tool =>
|
|
334
|
+
typeof value === "object" && value !== null && ToolKey in value;
|
|
335
|
+
|
|
336
|
+
export const toolSpecOf = (value: unknown): ToolSpec | undefined =>
|
|
337
|
+
isTool(value) ? value[ToolKey] : undefined;
|
|
338
|
+
|
|
339
|
+
/** A tool file's default export. */
|
|
340
|
+
export type ToolExport = Tool | ReadonlyArray<Tool>;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The authoring surface: what app code imports. Pure and synchronous —
|
|
3
|
+
* no effect dependency, no node builtins — so authored files run
|
|
4
|
+
* unmodified in any execution environment, including sandboxed workers.
|
|
5
|
+
*
|
|
6
|
+
* Host-side machinery lives behind subpaths: "apps/manifest" loads and
|
|
7
|
+
* validates authored sources, "apps/runner" executes them.
|
|
8
|
+
*/
|
|
9
|
+
export * from "./authoring/index";
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { AuthMethod, ParamSpec, Registry } from "../authoring/index";
|
|
2
|
+
|
|
3
|
+
type JsonValue = null | boolean | number | string | readonly JsonValue[] | {
|
|
4
|
+
readonly [key: string]: JsonValue;
|
|
5
|
+
};
|
|
6
|
+
type JsonSchemaObject = Readonly<Record<string, JsonValue>>;
|
|
7
|
+
|
|
8
|
+
interface CollectedClaim {
|
|
9
|
+
readonly origin: string;
|
|
10
|
+
readonly reach: readonly string[];
|
|
11
|
+
readonly params: Readonly<Record<string, ParamSpec>>;
|
|
12
|
+
readonly auth: Readonly<Record<string, AuthMethod>>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface CollectedRequirement {
|
|
16
|
+
readonly role: string;
|
|
17
|
+
readonly service: string;
|
|
18
|
+
readonly arity: "one" | "optional" | "many";
|
|
19
|
+
readonly grant?: string;
|
|
20
|
+
readonly description?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface CollectedTool {
|
|
24
|
+
readonly address: string;
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly description: string;
|
|
27
|
+
readonly inputSchema: JsonSchemaObject;
|
|
28
|
+
readonly annotations: {
|
|
29
|
+
readonly readOnly?: boolean;
|
|
30
|
+
readonly destructive?: boolean;
|
|
31
|
+
readonly scopes?: readonly string[];
|
|
32
|
+
};
|
|
33
|
+
readonly requires: readonly CollectedRequirement[];
|
|
34
|
+
readonly file: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface CollectedManifest {
|
|
38
|
+
readonly app: { readonly key: string; readonly label: string; readonly description: string };
|
|
39
|
+
readonly services: readonly CollectedClaim[];
|
|
40
|
+
readonly tools: readonly CollectedTool[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Result of compiling one authored input schema during registry projection. */
|
|
44
|
+
export type InputSchemaCompilation =
|
|
45
|
+
| { readonly value: JsonSchemaObject }
|
|
46
|
+
| { readonly problem: string };
|
|
47
|
+
|
|
48
|
+
/** Registry projection before the deployment-specific digest implementation. */
|
|
49
|
+
export type RegistryCollection =
|
|
50
|
+
| { readonly problems: readonly string[] }
|
|
51
|
+
| {
|
|
52
|
+
readonly problems: readonly [];
|
|
53
|
+
readonly manifest: CollectedManifest;
|
|
54
|
+
readonly canonical: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate and project an executed authoring registry into deterministic data.
|
|
59
|
+
*
|
|
60
|
+
* This function is deliberately closure-free: the workerd runner injects its
|
|
61
|
+
* source into the guest while the local manifest calls the same function
|
|
62
|
+
* directly. Schema compilation is supplied because each runtime owns its
|
|
63
|
+
* boundary decoder.
|
|
64
|
+
*/
|
|
65
|
+
export const collectRegistry = (
|
|
66
|
+
registry: Registry,
|
|
67
|
+
compileInputSchema: (input: object, where: string) => InputSchemaCompilation,
|
|
68
|
+
): RegistryCollection => {
|
|
69
|
+
const requirementKey = Symbol.for("executor/apps/requirement");
|
|
70
|
+
const problems = registry.problems.map((problem) => problem.message);
|
|
71
|
+
const app = registry.app;
|
|
72
|
+
|
|
73
|
+
const requirementSpecOf = (value: unknown) =>
|
|
74
|
+
typeof value === "object" && value !== null && requirementKey in value
|
|
75
|
+
? (value as Record<PropertyKey, unknown>)[requirementKey] as {
|
|
76
|
+
readonly service: {
|
|
77
|
+
readonly origin: string;
|
|
78
|
+
readonly auth?: Readonly<Record<string, unknown>>;
|
|
79
|
+
};
|
|
80
|
+
readonly arity: "one" | "optional" | "many";
|
|
81
|
+
readonly grant?: string;
|
|
82
|
+
readonly description?: string;
|
|
83
|
+
}
|
|
84
|
+
: undefined;
|
|
85
|
+
|
|
86
|
+
const canonicalJson = (value: unknown): string => {
|
|
87
|
+
const sortKeys = (node: unknown): unknown => {
|
|
88
|
+
if (Array.isArray(node)) return node.map(sortKeys);
|
|
89
|
+
if (typeof node === "object" && node !== null)
|
|
90
|
+
return Object.fromEntries(
|
|
91
|
+
Object.entries(node as Record<string, unknown>)
|
|
92
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
93
|
+
.map(([key, child]) => [key, sortKeys(child)]),
|
|
94
|
+
);
|
|
95
|
+
return node;
|
|
96
|
+
};
|
|
97
|
+
return JSON.stringify(sortKeys(value));
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const claims = new Map<string, CollectedClaim>();
|
|
101
|
+
for (const [origin, registered] of registry.services) {
|
|
102
|
+
if (registered.file !== "app.ts") continue;
|
|
103
|
+
const { decl } = registered;
|
|
104
|
+
claims.set(origin, {
|
|
105
|
+
origin,
|
|
106
|
+
reach: decl.reach ?? [],
|
|
107
|
+
params: decl.params ?? {},
|
|
108
|
+
auth: decl.auth ?? {},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const collectRequirement = (
|
|
113
|
+
role: string,
|
|
114
|
+
requirement: unknown,
|
|
115
|
+
where: string,
|
|
116
|
+
): CollectedRequirement | undefined => {
|
|
117
|
+
const spec = requirementSpecOf(requirement);
|
|
118
|
+
if (spec === undefined) {
|
|
119
|
+
problems.push(`${where}: requirement '${role}' is not a service/requirement value`);
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
const origin = spec.service.origin;
|
|
123
|
+
if (!claims.has(origin))
|
|
124
|
+
problems.push(`${where}: requirement '${role}' names ${origin}, which app.ts does not declare`);
|
|
125
|
+
if (spec.grant !== undefined && !(spec.grant in (spec.service.auth ?? {})))
|
|
126
|
+
problems.push(
|
|
127
|
+
`${where}: requirement '${role}' pins grant '${spec.grant}', not a auth method on ${origin}`,
|
|
128
|
+
);
|
|
129
|
+
return {
|
|
130
|
+
role,
|
|
131
|
+
service: origin,
|
|
132
|
+
arity: spec.arity,
|
|
133
|
+
...(spec.grant === undefined ? {} : { grant: spec.grant }),
|
|
134
|
+
...(spec.description === undefined ? {} : { description: spec.description }),
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
if (app === undefined) problems.push("app.ts must call defineApp({...})");
|
|
139
|
+
else if (typeof app.description !== "string" || app.description.trim().length === 0)
|
|
140
|
+
problems.push("app.ts: app description must not be empty");
|
|
141
|
+
|
|
142
|
+
for (const [file, summary] of registry.toolFiles) {
|
|
143
|
+
if (summary.definitions > 1 && summary.hasUnnamedTool)
|
|
144
|
+
problems.push(`${file}: multi-tool files must name every tool`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const tools: CollectedTool[] = [];
|
|
148
|
+
if (app !== undefined) {
|
|
149
|
+
for (const [name, registered] of registry.tools) {
|
|
150
|
+
const { spec, file } = registered;
|
|
151
|
+
const address = `${app.key}/${name}`;
|
|
152
|
+
if (typeof spec.description !== "string" || spec.description.trim().length === 0)
|
|
153
|
+
problems.push(`${file}: tool '${name}' description must not be empty`);
|
|
154
|
+
const requires = Object.entries(spec.requirements ?? {})
|
|
155
|
+
.flatMap(([role, requirement]) => {
|
|
156
|
+
const collected = collectRequirement(role, requirement, address);
|
|
157
|
+
return collected === undefined ? [] : [collected];
|
|
158
|
+
})
|
|
159
|
+
.sort((left, right) => left.role.localeCompare(right.role));
|
|
160
|
+
let compiled: InputSchemaCompilation;
|
|
161
|
+
try {
|
|
162
|
+
compiled = compileInputSchema(spec.input, address);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
compiled = {
|
|
165
|
+
problem: `${address}: input schema could not compile to JSON schema (${
|
|
166
|
+
error instanceof Error ? error.message : String(error)
|
|
167
|
+
})`,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if ("problem" in compiled) {
|
|
171
|
+
problems.push(compiled.problem);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
tools.push({
|
|
175
|
+
address,
|
|
176
|
+
name,
|
|
177
|
+
description: spec.description,
|
|
178
|
+
inputSchema: compiled.value,
|
|
179
|
+
annotations: spec.annotations ?? {},
|
|
180
|
+
requires,
|
|
181
|
+
file,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (problems.length > 0 || app === undefined) return { problems };
|
|
187
|
+
const services = [...claims.values()].sort((left, right) =>
|
|
188
|
+
left.origin.localeCompare(right.origin),
|
|
189
|
+
);
|
|
190
|
+
const sortedTools = [...tools].sort((left, right) => left.address.localeCompare(right.address));
|
|
191
|
+
const appResult = { key: app.key, label: app.label, description: app.description };
|
|
192
|
+
const manifest = { app: appResult, services, tools: sortedTools } satisfies CollectedManifest;
|
|
193
|
+
return {
|
|
194
|
+
problems: [],
|
|
195
|
+
manifest,
|
|
196
|
+
canonical: canonicalJson(manifest),
|
|
197
|
+
};
|
|
198
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Data, Schema } from "effect";
|
|
2
|
+
import type { AuthMethod, ParamSpec } from "../authoring/index";
|
|
3
|
+
|
|
4
|
+
/** A service claim projected from a registered declaration. */
|
|
5
|
+
export interface CollectedClaim {
|
|
6
|
+
readonly origin: string;
|
|
7
|
+
readonly reach: readonly string[];
|
|
8
|
+
readonly params: Readonly<Record<string, ParamSpec>>;
|
|
9
|
+
readonly auth: Readonly<Record<string, AuthMethod>>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A tool requirement projected from an authoring value. */
|
|
13
|
+
export interface CollectedRequirement {
|
|
14
|
+
readonly role: string;
|
|
15
|
+
readonly service: string;
|
|
16
|
+
readonly arity: "one" | "optional" | "many";
|
|
17
|
+
readonly grant?: string;
|
|
18
|
+
readonly description?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** JSON object accepted for a persisted tool input schema. */
|
|
22
|
+
export const JsonSchemaObject = Schema.Record(Schema.String, Schema.Json);
|
|
23
|
+
export type JsonSchemaObject = typeof JsonSchemaObject.Type;
|
|
24
|
+
|
|
25
|
+
/** A validated, publishable tool row. */
|
|
26
|
+
export interface ManifestTool {
|
|
27
|
+
readonly address: string;
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly description: string;
|
|
30
|
+
readonly inputSchema: JsonSchemaObject;
|
|
31
|
+
readonly annotations: {
|
|
32
|
+
readonly readOnly?: boolean;
|
|
33
|
+
readonly destructive?: boolean;
|
|
34
|
+
readonly scopes?: readonly string[];
|
|
35
|
+
};
|
|
36
|
+
readonly requires: readonly CollectedRequirement[];
|
|
37
|
+
readonly file: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The deterministic public description of an executor app. */
|
|
41
|
+
export interface AppManifestResult {
|
|
42
|
+
readonly digest: string;
|
|
43
|
+
readonly app: { readonly key: string; readonly label: string; readonly description: string };
|
|
44
|
+
readonly services: readonly CollectedClaim[];
|
|
45
|
+
readonly tools: readonly ManifestTool[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** All publish-time authoring problems from one registry load. */
|
|
49
|
+
export class PublishError extends Data.TaggedError("PublishError")<{
|
|
50
|
+
readonly problems: readonly string[];
|
|
51
|
+
}> {
|
|
52
|
+
override get message(): string {
|
|
53
|
+
return `publish failed:\n${this.problems.map((problem) => ` - ${problem}`).join("\n")}`;
|
|
54
|
+
}
|
|
55
|
+
}
|