diff --git a/.changeset/cf-computer-introspection.md b/.changeset/cf-computer-introspection.md new file mode 100644 index 00000000..0f3ebc23 --- /dev/null +++ b/.changeset/cf-computer-introspection.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +JavaScript backend code can import `cf:computer` for each module's TypeScript declarations, and `console.log` prints a host export's declaration. A module with no exports now runs as the body of the default export instead of failing. Configured modules can no longer use `cf:*` specifiers, and `ws:git` rejects calls that pass anything other than one options object of the options it takes; see [`cf:computer`](https://github.com/cloudflare/computer/blob/main/docs/17_isolate_javascript.md#cfcomputer). diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index a6d6ae01..5cff06c5 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -51,7 +51,7 @@ const result = await handle.result(); // result.value = { value: 42, persisted: "42" } ``` -The source is a real ES module, with static imports and literal dynamic imports. The module needs a default export, or the run fails before it starts. A default-exported function is called with `options.input`, and any other default value is the result. To run code that's already in a file, re-export it with `export { default } from "./main.js"`. +The source is a real ES module, with static imports and literal dynamic imports. A default-exported function is called with `options.input`, and any other default value is the result. To run code that's already in a file, re-export it with `export { default } from "./main.js"`. A module with named exports but no default fails before it starts. Put the module's work in that function. Each run loads the module first, then calls its default export, and the Workers runtime doesn't allow I/O while a module loads. So `node:fs` and host module calls only work once the function is running: @@ -67,7 +67,16 @@ export default async function () { } ``` -A call made while the module loads fails the run with an error that names the call, even if the code catches the error, since the work it asked for never happened. The run also fails if a promise rejects and nothing has handled it by the time the function finishes. Top-level `await` is fine for anything that doesn't do I/O. +A call made while the module loads fails the run with an error that names the call, even if the code catches the error, since the work it asked for never happened. The run also fails if a promise rejects and nothing has handled it by the time the function finishes. Top-level `await` is fine for anything that doesn't do I/O, and `console.log` at the top level is kept and printed once the module has loaded. + +A module with no exports at all is a script, and runs as the body of the default export instead. Its imports stay at the top and everything else moves into `export default async function () { ... }`, so top-level `await`, I/O, and `return` work, and line numbers stay as written. A script doesn't receive `options.input`: + +```js +import { readFile } from "node:fs/promises"; + +const text = await readFile("/workspace/a.txt", "utf8"); +return text.length; +``` The returned value becomes the result's `value` and must be JSON-compatible plain data. As with `JSON.stringify`, an `undefined` object field is left out, so `{ kept: 1, dropped: undefined }` completes as `{ kept: 1 }`, and returning `undefined` gives `null`. A function, a class instance such as a `Date`, an `undefined` array item, or a cycle fails the run. `options.input` is checked the same way. @@ -145,7 +154,7 @@ Caller source can import four kinds of module, and all of them are fixed when th | Kind | Configured with | Runs in | Example | | --- | --- | --- | --- | -| Built in | Always installed | The isolate, backed by the Workspace | `node:fs`, `node:fs/promises` | +| Built in | Always installed | The isolate, backed by the Workspace | `node:fs`, `node:fs/promises`, `cf:computer` | | Node.js | `nodejs_compat` in `compatibilityFlags`, the default | The isolate, provided by the runtime | `node:path`, `node:crypto` | | Source | `modules: { name: "source" }` | The isolate | a bundled library | | Host | `modules: { "ws:name": { fn } }`, or a factory | The Durable Object | `ws:git`, `ws:container`, `ws:tools`, your own | @@ -184,20 +193,65 @@ The backend describes its modules for a model in `backend.description`, which `w ```text `command` is ECMAScript module source, run in an isolated JavaScript runtime. Relative imports resolve from `cwd` in the workspace. -Put the work in `export default async function (input) { ... }` and call `node:fs` and the other modules below inside it, since the module's top level can't do I/O. To run a file you've already written, re-export it: `export { default } from "./main.js"`. +Put the work in `export default async function (input) { ... }` and call `node:fs` and the other modules below inside it, since the module's top level can't do I/O. To run a file you've already written, re-export it: `export { default } from "./main.js"`. A module with no exports runs as the body of that function instead, so top-level `await`, I/O, and `return` work there. Code has no direct network access. Modules code can import: - `node:fs/promises` (also `node:fs`): the workspace's files. ... - `tar-stream`: a bundled library. -- `ws:git`: The workspace's Git repository tools: `status({ dir })`, ... +- `ws:git`: The workspace's Git repository: `status({ dir })`, ... - `ws:container`: Runs shell commands in a full Linux container that shares this workspace's files. ... - `ws:weather`: exports `forecast`. +- `cf:computer`: `docs(module)` returns a module's TypeScript declarations and examples, ... - Node.js built-ins: `node:path`, `node:url`, ... Bare names such as `path` work too. ``` A factory adds its own text through a `description` property, as the prebuilt modules do. The backend reads it each time it describes itself, not once when it is constructed, so a factory can define `description` as a getter and describe something that exists only later, as `ws:tools` does with the agent's tools. An object of functions is listed by its export names; say more about it in the `exec` tool's backend description if the model needs it. +The description is one line per module, so it stays short in every prompt. The rest is in `cf:computer`, below. + +### `cf:computer` + +A host module's code runs in the Durable Object, so the isolate only holds stubs that forward each call. `String(fn)` on one shows nothing useful about how to call it. `cf:computer` is built in and gives code each module's TypeScript declarations instead: + +```js +import * as git from "ws:git"; +import { docs, types } from "cf:computer"; + +export default () => ({ + modules: docs(), // every module, one line each + git: docs(git), // ws:git's summary, declarations, and examples, as markdown + cli: types(git.cli), // one export's declaration, with the types it names + byName: types("ws:git"), // a specifier works too, even for a module that failed to import +}); +``` + +Printing works the same way. `console.log(git.cli)` prints the export's declaration with a `{ [host code] }` body, as V8 prints a built-in function with `{ [native code] }`, and `console.log(git)` prints the whole module's declaration. `console.log`, `console.info`, `console.debug`, `console.warn`, `console.error`, and `console.dir` format other values the way Node.js does: `{ a: 1, b: 'x' }`, `Map(1) { 'k' => 1 }`, `[Circular]`, and an error's stack. + +A factory gives its declarations through a `types` property, keyed by the name each one declares. An entry named after an export declares that export. Any other entry declares a type the exports use, and is shown with each export that names it. `examples` adds short snippets to `docs`: + +```ts +modules: { + "ws:weather": Object.assign( + () => ({ forecast: ([city]) => lookUpForecast(String(city)) }), + { + description: "The weather.", + types: { + Forecast: "interface Forecast { high: number; low: number }", + forecast: "/** The forecast for a city. */\nfunction forecast(city: string): Promise;", + }, + examples: ['const { high } = await forecast("Lisbon");'], + }, + ), +} +``` + +Code that guesses wrong is pointed back here. A call to an export a host module doesn't have fails with `is not a function`, and the run adds which `docs(...)` call lists the real ones. An import of a module that isn't configured, such as `node:child_process`, names `docs()`. `ws:git` refuses an option it doesn't take and lists the ones it does. + +The prebuilt modules all declare their exports, and `ws:tools` writes each tool's declaration from its description and input schema. An export with no declaration, such as one from a plain object of functions, is shown taking and returning `unknown`. `node:fs` and `node:fs/promises` are declared as the workspace implements them, which differs from Node.js in places: `stat().mtime` is a number, and there are no sync functions. A source module has no declarations, and a Node.js built-in is described as the Node.js API. + +The backend reads `types` and `examples` when it connects, with the functions the factory builds, so the two always agree. Like `description`, either can be a getter. Every specifier under `cf:` is reserved: no configured module may use one, and importing any other than `cf:computer` fails before the Worker is created. + ### Built-in filesystem Filesystem access uses the familiar asynchronous Node API, but is backed by the durable Workspace rather than an isolate-local filesystem. Both forms are installed automatically: @@ -271,7 +325,7 @@ Specifiers and the export names of an object are checked at construction. A fact import { clone, diff, status, log, cli } from "ws:git"; ``` -`createGitModule()` from `@cloudflare/computer/modules/git` wraps the Workspace's Git client. Every `dir` and `cwd` is confined to the backend root, `clone` and `cli` need a read-write backend, and `cli` treats a leading `-C ` as its working directory, confined the same way, while rejecting any other `-C`, `--git-dir`, and `--work-tree`. Clone, fetch, pull, push, `ls-remote`, and submodule commands run from the host, even when the Dynamic Worker has `globalOutbound: null`, so they are denied unless you pass `createGitModule({ allowNetwork: true })`. +`createGitModule()` from `@cloudflare/computer/modules/git` wraps the Workspace's Git client. Each export takes one options object, such as `cli({ argv: ["log", "--oneline"] })`, where `argv` leaves out `"git"`. A call that passes a string, an array, or an `argv` starting with `"git"` rejects with the shape it expects, rather than running git with no arguments. Every `dir` and `cwd` is confined to the backend root, `clone` and `cli` need a read-write backend, and `cli` treats a leading `-C ` as its working directory, confined the same way, while rejecting any other `-C`, `--git-dir`, and `--work-tree`. Clone, fetch, pull, push, `ls-remote`, and submodule commands run from the host, even when the Dynamic Worker has `globalOutbound: null`, so they are denied unless you pass `createGitModule({ allowNetwork: true })`. ### `ws:artifacts` @@ -370,6 +424,17 @@ On a backend with `access: "read"`, only tools marked `readOnly` run. Every othe The module's description lists the names code can import. With a function of tools, the function is called again each time the backend describes itself, so the `exec` tool, built after the Workspace, sees the current list. A list is read as given; any other iterable may be single-use, so it is not read for the description, and neither is a function that throws. +A binding may also carry the tool's `description` and its `inputSchema` as JSON Schema, and `forPiTools()` and `forAISDKTools()` fill both in. The module then declares each export for `cf:computer`, so `types(read)` shows the arguments the tool takes: + +```ts +/** Read a file. */ +function read(input: { + /** The file's path. */ + path: string; + offset?: number; +}): Promise; +``` + ### `ws:container` `createContainerModule()` from `@cloudflare/computer/modules/container` lets JavaScript run shell commands in the Workspace's container backend. With it, JavaScript is the only backend the model sees, and the container is something that JavaScript can call: diff --git a/examples/worker-javascript/src/skills/exec/SKILL.md b/examples/worker-javascript/src/skills/exec/SKILL.md index 0e087455..bdbc539d 100644 --- a/examples/worker-javascript/src/skills/exec/SKILL.md +++ b/examples/worker-javascript/src/skills/exec/SKILL.md @@ -49,6 +49,10 @@ export default async function (input) { a helper module with one call and import it in the next. - There is no `node_modules` lookup and no npm. Beyond the modules above, only the ones the host configures are available. +- `cf:computer` tells you how to call any of them. `docs()` lists every + module, and `docs("node:fs/promises")` returns one module's + TypeScript declarations and examples. `console.log` of a host + module's export prints its declaration too. ## Limits diff --git a/packages/computer/src/backends/worker-javascript/introspection.test.ts b/packages/computer/src/backends/worker-javascript/introspection.test.ts new file mode 100644 index 00000000..192b5cdd --- /dev/null +++ b/packages/computer/src/backends/worker-javascript/introspection.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; + +import { buildModuleCatalog } from "./introspection.js"; + +describe("buildModuleCatalog", () => { + const catalog = buildModuleCatalog("Modules.", [ + { + _tag: "host", + specifier: "ws:weather", + summary: "the weather.", + exports: ["forecast", "alerts", "raw"], + types: { + Place: "interface Place { city: string }", + Forecast: "interface Forecast { place: Place; high: number }", + Unused: "interface Unused { x: number }", + forecast: "/** The forecast. */\nfunction forecast(place: Place): Promise;", + alerts: "function alerts(): Promise;", + }, + examples: ['await forecast({ city: "Lisbon" });'], + }, + ]); + const weather = catalog.modules["ws:weather"]; + + it("puts the types an export names, and the ones they name, before its declaration", () => { + expect(weather?.exports.forecast?.types).toBe( + [ + "interface Place { city: string }", + "interface Forecast { place: Place; high: number }", + "/** The forecast. */\nfunction forecast(place: Place): Promise;", + ].join("\n\n"), + ); + expect(weather?.exports.alerts?.types).toBe("function alerts(): Promise;"); + }); + + it("prints an export with its body named for where it runs", () => { + expect(weather?.exports.alerts?.display).toBe( + "function alerts(): Promise { [host code] }", + ); + }); + + it("declares an export with no declaration as taking and returning unknown", () => { + expect(weather?.exports.raw?.types).toBe("function raw(...args: unknown[]): Promise;"); + expect(weather?.docs).toContain( + "No declarations were given for `raw`, so it is shown taking and returning `unknown`.", + ); + }); + + it("declares the whole module, with the other types first and the exports in order", () => { + const types = weather?.types ?? ""; + expect(types.startsWith('declare module "ws:weather" {\n interface Place')).toBe(true); + expect(types).toContain(" interface Unused { x: number }"); + expect(types.indexOf("function forecast")).toBeLessThan(types.indexOf("function alerts")); + expect(types.indexOf("function alerts")).toBeLessThan(types.indexOf("function raw")); + expect(types.endsWith("\n}")).toBe(true); + }); + + it("documents a module with its summary, declarations, and examples", () => { + expect(weather?.docs).toMatch( + /^# ws:weather\n\nThe weather\.\n\n```ts\ndeclare module "ws:weather" \{[\s\S]*\n```\n\n/, + ); + expect(weather?.docs).toContain('Examples:\n\n```js\nawait forecast({ city: "Lisbon" });\n```'); + }); + + it("keeps the overview as given", () => { + expect(catalog.overview).toBe("Modules."); + }); +}); diff --git a/packages/computer/src/backends/worker-javascript/introspection.ts b/packages/computer/src/backends/worker-javascript/introspection.ts new file mode 100644 index 00000000..83087bbb --- /dev/null +++ b/packages/computer/src/backends/worker-javascript/introspection.ts @@ -0,0 +1,577 @@ +// What code in the isolate can learn about the modules it imports. +// +// import * as git from "ws:git"; +// import { docs, types } from "cf:computer"; +// docs(); // every module, one line each +// docs(git); // one module: its summary, declarations, examples +// types(git.cli); // one export's declaration +// +// A host module's code runs in the Durable Object, so the isolate only +// ever holds a stub that forwards the call. The stub has nothing to +// show, so the host sends each module's TypeScript declarations +// instead. The backend renders them here, once per connection, and +// the isolate reads the result as data: `cf:computer` looks entries +// up, a host export prints its declaration, and `console.log` formats +// values the way Node.js does. + +import type { WorkspaceModuleTypes } from "../../runtime/types.js"; + +/** The built-in module code imports to read about the others. */ +export const COMPUTER_MODULE = "cf:computer"; + +/** The internal module holding the catalog, the export registry, and the console formatter. */ +export const INTROSPECTION_MODULE = "workspace-introspection.js"; + +/** One line for the backend's description, beside the other modules. */ +export const COMPUTER_DESCRIPTION = + "- `cf:computer`: how to call every other module. Read a module's docs before you first call it. `docs(module)` returns a module's TypeScript declarations and examples, and `docs()` lists every module. Pass a namespace (`import * as git from \"ws:git\"`), a specifier string, or one export. `types(...)` returns the declarations alone. Printing a host export with `console.log` shows its declaration too."; + +/** What the isolate knows about every module it can import. */ +export interface ModuleCatalog { + /** What `docs()` returns: every module, one line each. */ + readonly overview: string; + /** Each importable module, by specifier. */ + readonly modules: Readonly>; +} + +/** One module in the catalog. */ +export interface CatalogModule { + /** What `docs(module)` returns. */ + readonly docs: string; + /** What `types(module)` returns. */ + readonly types: string; + /** A host module's exports, by name. Empty for any other module. */ + readonly exports: Readonly>; +} + +/** One host module export in the catalog. */ +export interface CatalogExport { + /** What `types(fn)` and `docs(fn)` return: the declaration and the types it names. */ + readonly types: string; + /** What printing the function shows. */ + readonly display: string; +} + +/** A module to describe, before rendering. */ +export type CatalogEntry = + | { + readonly _tag: "host"; + readonly specifier: string; + readonly summary: string; + /** The export names the module built on this connection. */ + readonly exports: readonly string[]; + readonly types: WorkspaceModuleTypes | undefined; + readonly examples: readonly string[] | undefined; + } + | { + readonly _tag: "declared"; + readonly specifier: string; + readonly summary: string; + readonly types: WorkspaceModuleTypes; + readonly examples: readonly string[] | undefined; + } + | { + readonly _tag: "undeclared"; + readonly specifier: string; + readonly summary: string; + /** Why there are no declarations, and where to look instead. */ + readonly note: string; + }; + +/** + * Render every module for the isolate. + * + * @param overview - What `docs()` returns. + * @param entries - The modules code can import. + * @returns The catalog the isolate reads. + */ +export function buildModuleCatalog( + overview: string, + entries: readonly CatalogEntry[], +): ModuleCatalog { + const modules: Record = Object.create(null); + for (const entry of entries) modules[entry.specifier] = renderEntry(entry); + return { overview, modules }; +} + +function renderEntry(entry: CatalogEntry): CatalogModule { + switch (entry._tag) { + case "host": { + const declared = entry.types ?? {}; + const types = moduleDeclaration(entry.specifier, declared, entry.exports); + const exports: Record = Object.create(null); + for (const name of entry.exports) { + const declaration = declared[name] ?? fallbackDeclaration(name); + const related = relatedTypes(declared, entry.exports, declaration); + exports[name] = { + types: [...related, declaration].join("\n\n"), + display: [...related, hostBody(declaration)].join("\n\n"), + }; + } + const missing = entry.exports.filter((name) => declared[name] === undefined); + const note = + missing.length === 0 + ? undefined + : `No declarations were given for ${missing.map((name) => `\`${name}\``).join(", ")}, so ${missing.length === 1 ? "it is" : "they are"} shown taking and returning \`unknown\`.`; + return { + docs: renderDocs(entry.specifier, entry.summary, types, entry.examples, note), + types, + exports, + }; + } + case "declared": { + const names = Object.keys(entry.types); + const types = moduleDeclaration(entry.specifier, entry.types, names); + return { + docs: renderDocs(entry.specifier, entry.summary, types, entry.examples, undefined), + types, + exports: Object.create(null), + }; + } + case "undeclared": { + return { + docs: `# ${entry.specifier}\n\n${sentence(entry.summary)}\n\n${entry.note}`, + types: `// ${entry.note}`, + exports: Object.create(null), + }; + } + } +} + +function renderDocs( + specifier: string, + summary: string, + types: string, + examples: readonly string[] | undefined, + note: string | undefined, +): string { + const fence = "```"; + const sections = [`# ${specifier}`, sentence(summary), `${fence}ts\n${types}\n${fence}`]; + if (note !== undefined) sections.push(note); + if (examples !== undefined && examples.length > 0) { + sections.push("Examples:", `${fence}js\n${examples.join("\n")}\n${fence}`); + } + return sections.join("\n\n"); +} + +// Summaries are written to follow a module's name in a list, so most +// start lowercase. +function sentence(text: string): string { + return text.charAt(0).toUpperCase() + text.slice(1); +} + +// The declarations that are not exports come first, since the exports +// name them, then every export in the order the module built them. +function moduleDeclaration( + specifier: string, + types: WorkspaceModuleTypes, + exports: readonly string[], +): string { + const exported = new Set(exports); + const body = [ + ...Object.keys(types) + .filter((name) => !exported.has(name)) + .map((name) => types[name] ?? ""), + ...exports.map((name) => types[name] ?? fallbackDeclaration(name)), + ]; + return `declare module ${JSON.stringify(specifier)} {\n${indent(body.join("\n\n"))}\n}`; +} + +// The non-export declarations a declaration names, and the ones those +// name in turn, in the order the module declared them. +function relatedTypes( + types: WorkspaceModuleTypes, + exports: readonly string[], + declaration: string, +): string[] { + const exported = new Set(exports); + const candidates = Object.keys(types).filter((name) => !exported.has(name)); + const found = new Set(); + const pending = [declaration]; + while (pending.length > 0) { + const text = pending.pop() ?? ""; + for (const name of candidates) { + if (found.has(name) || !mentions(text, name)) continue; + found.add(name); + pending.push(types[name] ?? ""); + } + } + return candidates.filter((name) => found.has(name)).map((name) => types[name] ?? ""); +} + +function mentions(text: string, name: string): boolean { + const escaped = name.replace(/[$]/g, "\\$"); + return new RegExp(`(^|[^A-Za-z0-9_$])${escaped}($|[^A-Za-z0-9_$])`).test(text); +} + +function fallbackDeclaration(name: string): string { + return `function ${name}(...args: unknown[]): Promise;`; +} + +// V8 prints a built-in function as `function push() { [native code] }`. +// A host export prints the same way, with the body the isolate can't +// see named for where it runs. +function hostBody(declaration: string): string { + const trimmed = declaration.trimEnd(); + return trimmed.endsWith(";") ? `${trimmed.slice(0, -1)} { [host code] }` : trimmed; +} + +function indent(text: string): string { + return text + .split("\n") + .map((line) => (line === "" ? line : ` ${line}`)) + .join("\n"); +} + +/** The declarations of the workspace's `node:fs/promises`, which differs from Node's in places. */ +export const NODE_FS_PROMISES_TYPES: WorkspaceModuleTypes = { + Stats: `/** \`mtime\` is milliseconds since the epoch, not a Date. */ +interface Stats { + name: string; + inode: number; + mode: number; + mtime: number; + size: number; + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; +}`, + Dirent: `interface Dirent { + name: string; + isFile(): boolean; + isDirectory(): boolean; + isSymbolicLink(): boolean; +}`, + readFile: `/** Without an encoding, the file's bytes. Only "utf8" is supported as an encoding. */ +function readFile(path: string): Promise; +function readFile(path: string, options: "utf8" | { encoding: "utf8" }): Promise;`, + writeFile: `/** Creates missing parent directories. \`flag: "wx"\` fails if the file exists. */ +function writeFile(path: string, data: string | Uint8Array, options?: { flag?: "w" | "wx" }): Promise;`, + mkdir: "function mkdir(path: string, options?: { recursive?: boolean }): Promise;", + rm: "function rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise;", + readdir: `/** Entry names, or entries with their types when \`withFileTypes\` is set. Defaults to the current directory. */ +function readdir(path?: string): Promise; +function readdir(path: string, options: { withFileTypes: true }): Promise;`, + stat: "function stat(path: string): Promise;", + lstat: + "/** Like stat, but describes a symlink itself rather than its target. */\nfunction lstat(path: string): Promise;", + readlink: "function readlink(path: string): Promise;", + symlink: "function symlink(target: string, path: string): Promise;", + chmod: "function chmod(path: string, mode: number): Promise;", + access: + "/** Rejects with code ENOENT when the path does not exist. */\nfunction access(path: string): Promise;", +}; + +/** The declarations of the workspace's `node:fs`: the promise API, also under `promises`. There are no sync or callback functions. */ +export const NODE_FS_TYPES: WorkspaceModuleTypes = { + ...NODE_FS_PROMISES_TYPES, + promises: + '/** The same functions as `node:fs/promises`. */\nconst promises: typeof import("node:fs/promises");', +}; + +/** The declarations of `cf:computer`. */ +export const COMPUTER_TYPES: WorkspaceModuleTypes = { + ModuleTarget: `/** + * A module specifier such as "ws:git", a namespace from \`import * as git from "ws:git"\`, + * or one function a host module exports. + */ +type ModuleTarget = string | object | ((...args: never[]) => unknown);`, + docs: `/** + * A module's summary, TypeScript declarations, and examples, as markdown. + * With no argument, every module code can import, one line each. + * With one host export, that export's declaration. + */ +function docs(target?: ModuleTarget): string;`, + types: `/** + * A module's TypeScript declarations, or one host export's. + * With no argument, every module's declarations. + */ +function types(target?: ModuleTarget): string;`, +}; + +/** + * The internal module the isolate reads the catalog from. Host stubs + * register through it, `cf:computer` re-exports its lookups, and the + * runner formats console output with it. + * + * @param catalog - Every module code can import, rendered. + * @returns The module's source. + */ +export function introspectionModuleSource(catalog: ModuleCatalog): string { + return ` + const catalog = ${JSON.stringify(catalog)}; + const inspectCustom = Symbol.for("nodejs.util.inspect.custom"); + // Every host export, so a function or a namespace leads back to the + // module it came from. A namespace can't carry a marker of its own. + const hostExports = new WeakMap(); + + export function hostExport(specifier, name, fn) { + Object.defineProperty(fn, "name", { value: name }); + const entry = catalog.modules[specifier]?.exports[name]; + if (entry !== undefined) { + Object.defineProperty(fn, "toString", { value: () => entry.display }); + Object.defineProperty(fn, inspectCustom, { value: () => entry.display }); + } + hostExports.set(fn, { specifier, name }); + return fn; + } + + function isNamespace(value) { + return value !== null && typeof value === "object" && + Object.prototype.toString.call(value) === "[object Module]"; + } + + function moduleNamed(specifier, caller) { + const entry = catalog.modules[specifier] ?? catalog.modules["node:" + specifier]; + if (entry === undefined) { + throw new Error( + caller + "(" + JSON.stringify(specifier) + "): there is no such module. Modules code can import: " + + Object.keys(catalog.modules).join(", ") + "." + ); + } + return entry; + } + + function namespaceModule(namespace) { + for (const key of Object.keys(namespace)) { + let value; + try { value = namespace[key]; } catch { continue; } + const found = typeof value === "function" ? hostExports.get(value) : undefined; + if (found !== undefined) return found.specifier; + } + return undefined; + } + + function resolve(target, caller) { + if (typeof target === "string") return { module: moduleNamed(target, caller) }; + if (typeof target === "function") { + const found = hostExports.get(target); + if (found === undefined) { + throw new TypeError( + caller + "() was passed a function no host module exports. Pass a specifier such as " + + caller + "(\\"ws:git\\"), a namespace from import * as, or a host export." + ); + } + const module = moduleNamed(found.specifier, caller); + return { module, export: module.exports[found.name] }; + } + if (isNamespace(target)) { + const specifier = namespaceModule(target); + if (specifier === undefined) { + throw new TypeError( + caller + "() can only find a host module from its namespace. For any other module, pass its specifier, such as " + + caller + "(\\"node:fs\\")." + ); + } + return { module: moduleNamed(specifier, caller) }; + } + throw new TypeError( + caller + "() takes a module specifier, a namespace from import * as, or a host export." + ); + } + + export function docs(target) { + if (target === undefined) return catalog.overview; + const found = resolve(target, "docs"); + return found.export !== undefined ? found.export.types : found.module.docs; + } + + export function types(target) { + if (target === undefined) { + return Object.values(catalog.modules).map((module) => module.types).join("\\n\\n"); + } + const found = resolve(target, "types"); + return found.export !== undefined ? found.export.types : found.module.types; + } + + // console.log in Node.js prints strings as they are and inspects + // everything else. This follows its layout closely enough that a + // model reads the output as it would Node's. + export function formatConsole(args) { + return args.map((value) => typeof value === "string" ? value : inspect(value)).join(" "); + } + + export function inspect(value) { + return format(value, 0, new Set()); + } + + const MAX_DEPTH = 3; + const MAX_ITEMS = 100; + const LINE_WIDTH = 72; + + function format(value, depth, seen) { + switch (typeof value) { + case "string": return depth === 0 ? value : quote(value); + case "number": return Object.is(value, -0) ? "-0" : String(value); + case "bigint": return String(value) + "n"; + case "boolean": return String(value); + case "undefined": return "undefined"; + case "symbol": return value.toString(); + case "function": return formatFunction(value, depth); + } + if (value === null) return "null"; + if (seen.has(value)) return "[Circular]"; + if (isNamespace(value)) return formatNamespace(value, depth, seen); + const custom = customInspect(value, depth); + if (custom !== undefined) return custom; + if (value instanceof Error) return formatError(value); + if (value instanceof Date) return Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString(); + if (value instanceof RegExp) return String(value); + if (value instanceof Promise) return "Promise { }"; + if (value instanceof WeakMap) return "WeakMap { }"; + if (value instanceof WeakSet) return "WeakSet { }"; + if (depth > MAX_DEPTH) return Array.isArray(value) ? "[Array]" : "[" + (constructorName(value) ?? "Object") + "]"; + seen.add(value); + try { + if (Array.isArray(value)) return group("", "[", "]", listItems(value, depth, seen), depth); + if (value instanceof Map) { + const items = []; + for (const [key, item] of value) { + if (items.length >= MAX_ITEMS) { items.push("... " + (value.size - MAX_ITEMS) + " more items"); break; } + items.push(format(key, depth + 1, seen) + " => " + format(item, depth + 1, seen)); + } + return group("Map(" + value.size + ") ", "{", "}", items, depth); + } + if (value instanceof Set) { + return group("Set(" + value.size + ") ", "{", "}", listItems([...value], depth, seen), depth); + } + if (ArrayBuffer.isView(value) && !(value instanceof DataView)) { + return group(constructorName(value) + "(" + value.length + ") ", "[", "]", listItems(Array.from(value), depth, seen), depth); + } + if (value instanceof ArrayBuffer) return "ArrayBuffer { byteLength: " + value.byteLength + " }"; + return formatObject(value, depth, seen); + } finally { + seen.delete(value); + } + } + + function customInspect(value, depth) { + let hook; + try { hook = value[inspectCustom]; } catch { return undefined; } + if (typeof hook !== "function") return undefined; + try { + const result = hook.call(value, MAX_DEPTH - depth, { depth: MAX_DEPTH - depth }, inspect); + return typeof result === "string" ? result : inspect(result); + } catch { + return undefined; + } + } + + function formatFunction(fn, depth) { + const host = hostExports.get(fn); + if (host !== undefined && depth === 0) { + const entry = catalog.modules[host.specifier]?.exports[host.name]; + if (entry !== undefined) return entry.display; + } + const source = Function.prototype.toString.call(fn); + const name = fn.name; + const label = /^class[\\s{]/.test(source) + ? "[class " + (name || "(anonymous)") + "]" + : "[Function: " + (name || "(anonymous)") + "]"; + return host === undefined ? label : label + " (" + host.specifier + ")"; + } + + function formatNamespace(namespace, depth, seen) { + const specifier = namespaceModule(namespace); + if (specifier !== undefined && depth === 0) { + return "[Module: " + specifier + "]\\n" + catalog.modules[specifier].types; + } + if (specifier !== undefined) return "[Module: " + specifier + "]"; + seen.add(namespace); + try { + return formatObject(namespace, depth, seen, "[Module: null prototype] "); + } finally { + seen.delete(namespace); + } + } + + function formatError(error) { + const stack = typeof error.stack === "string" && error.stack !== "" ? error.stack : String(error); + const extras = Object.keys(error).filter((key) => key !== "stack" && key !== "message"); + if (extras.length === 0) return stack; + const fields = extras.map((key) => key + ": " + format(error[key], 1, new Set([error]))); + return stack + " { " + fields.join(", ") + " }"; + } + + function formatObject(value, depth, seen, prefix) { + const keys = Object.keys(value); + const items = []; + for (const key of keys) { + if (items.length >= MAX_ITEMS) { items.push("... " + (keys.length - MAX_ITEMS) + " more properties"); break; } + items.push(propertyKey(key) + ": " + formatProperty(value, key, depth, seen)); + } + const label = prefix ?? objectPrefix(value); + if (items.length === 0) return label + "{}"; + return group(label, "{", "}", items, depth); + } + + // Getters are named rather than called, as Node.js does, and a + // namespace binding read before it is initialized throws. + function formatProperty(value, key, depth, seen) { + let descriptor; + try { + descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor?.get !== undefined && descriptor.set !== undefined) return "[Getter/Setter]"; + if (descriptor?.get !== undefined) return "[Getter]"; + if (descriptor?.set !== undefined) return "[Setter]"; + return format(value[key], depth + 1, seen); + } catch { + return ""; + } + } + + function objectPrefix(value) { + const prototype = Object.getPrototypeOf(value); + if (prototype === null) return "[Object: null prototype] "; + const name = constructorName(value); + return name === undefined || name === "Object" ? "" : name + " "; + } + + function constructorName(value) { + try { + const name = value.constructor?.name; + return typeof name === "string" && name !== "" ? name : undefined; + } catch { + return undefined; + } + } + + function listItems(values, depth, seen) { + const items = values.slice(0, MAX_ITEMS).map((item) => format(item, depth + 1, seen)); + if (values.length > MAX_ITEMS) items.push("... " + (values.length - MAX_ITEMS) + " more items"); + return items; + } + + function group(prefix, open, close, items, depth) { + if (items.length === 0) return prefix + open + close; + const single = prefix + open + " " + items.join(", ") + " " + close; + if (single.length <= LINE_WIDTH && !single.includes("\\n")) return single; + const pad = " ".repeat(depth + 1); + const lines = items.map((item) => pad + item.split("\\n").join("\\n" + pad)); + return prefix + open + "\\n" + lines.join(",\\n") + "\\n" + " ".repeat(depth) + close; + } + + function propertyKey(key) { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : quote(key); + } + + function quote(text) { + return "'" + text + .replace(/\\\\/g, "\\\\\\\\") + .replace(/'/g, "\\\\'") + .replace(/\\n/g, "\\\\n") + .replace(/\\r/g, "\\\\r") + .replace(/\\t/g, "\\\\t") + "'"; + } + `; +} + +/** + * The source of `cf:computer`, which re-exports the lookups. + * + * @param introspectionImport - The path from `cf:computer` to the introspection module. + * @returns The module's source. + */ +export function computerModuleSource(introspectionImport: string): string { + return `export { docs, types } from ${JSON.stringify(introspectionImport)};\n`; +} diff --git a/packages/computer/src/backends/worker-javascript/module-graph.ts b/packages/computer/src/backends/worker-javascript/module-graph.ts index e6522e95..0c85a5ba 100644 --- a/packages/computer/src/backends/worker-javascript/module-graph.ts +++ b/packages/computer/src/backends/worker-javascript/module-graph.ts @@ -7,6 +7,19 @@ import type { WorkspaceModuleFunctions, WorkspaceRuntimeLoader, } from "../../runtime/types.js"; +import { + buildModuleCatalog, + COMPUTER_DESCRIPTION, + COMPUTER_MODULE, + COMPUTER_TYPES, + type CatalogEntry, + computerModuleSource, + INTROSPECTION_MODULE, + introspectionModuleSource, + type ModuleCatalog, + NODE_FS_PROMISES_TYPES, + NODE_FS_TYPES, +} from "./introspection.js"; export type JavaScriptModuleMap = WorkspaceRuntimeLoader extends { load(code: { modules: infer Modules }): unknown; @@ -80,16 +93,28 @@ const EXPORT_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/; // `await import(...)`. const RESERVED_EXPORT_NAMES = new Set(["default", "then"]); +// Code that reaches for a module it doesn't have, such as +// node:child_process to run git, needs to learn what it has instead. +const LIST_MODULES = `\`docs()\` from ${COMPUTER_MODULE} lists the modules code can import.`; + /** The `modules` option, parsed once when the backend is constructed. */ export interface ParsedModules { readonly source: Readonly>; readonly host: ReadonlyMap; /** One markdown bullet per importable module, for a model. */ readonly description: string; + /** + * Each configured module's summary, as its bullet shows it, in the + * order it was configured. A factory's description is read again on + * every call. + */ + summaries(): ReadonlyArray<{ readonly specifier: string; readonly summary: string }>; } -const FILESYSTEM_DESCRIPTION = - "- `node:fs/promises` (also `node:fs`): the workspace's files. `readFile`, `writeFile`, `mkdir`, `rm`, `readdir`, `stat`, `lstat`, `readlink`, `symlink`, `chmod`, and `access`. Async only."; +const FILESYSTEM_SUMMARY = + "the workspace's files. `readFile`, `writeFile`, `mkdir`, `rm`, `readdir`, `stat`, `lstat`, `readlink`, `symlink`, `chmod`, and `access`. Async only."; +const FILESYSTEM_DESCRIPTION = `- \`node:fs/promises\` (also \`node:fs\`): ${FILESYSTEM_SUMMARY}`; +const COMPUTER_SUMMARY = COMPUTER_DESCRIPTION.replace(/^- `cf:computer`: /, ""); /** * Parse the backend's `modules` option: split it into bundled source @@ -105,12 +130,20 @@ const FILESYSTEM_DESCRIPTION = export function parseModules(modules: Readonly>): ParsedModules { const source: Record = Object.create(null); const host = new Map(); - const lines: Array string)> = [FILESYSTEM_DESCRIPTION]; + const summaries: Array<{ specifier: string; summary: () => string }> = []; + const add = (specifier: string, summary: () => string) => summaries.push({ specifier, summary }); for (const [specifier, module] of Object.entries(modules)) { - const name = `\`${specifier}\``; - if (BUILT_IN_MODULES.some((builtIn) => builtIn === specifier)) { + if ( + BUILT_IN_MODULES.some((builtIn) => builtIn === specifier) || + specifier === COMPUTER_MODULE + ) { throw new Error(`Module ${JSON.stringify(specifier)} is built in and cannot be replaced.`); } + if (specifier.startsWith("cf:")) { + throw new Error( + `Module ${JSON.stringify(specifier)} uses the cf:* namespace, which is only for built-in modules.`, + ); + } if (typeof module === "string") { if (specifier.startsWith("ws:")) { throw new Error( @@ -121,7 +154,7 @@ export function parseModules(modules: Readonly>) throw new Error(`Module ${JSON.stringify(specifier)} uses a reserved module name.`); } source[specifier] = module; - lines.push(`- ${name}: a bundled library.`); + add(specifier, () => "a bundled library."); continue; } if (!HOST_SPECIFIER.test(specifier)) { @@ -131,7 +164,7 @@ export function parseModules(modules: Readonly>) } if (typeof module === "function") { host.set(specifier, module); - lines.push(() => `- ${name}: ${module.description ?? "a host module."}`); + add(specifier, () => module.description ?? "a host module."); continue; } if (module === null || typeof module !== "object" || Array.isArray(module)) { @@ -142,17 +175,117 @@ export function parseModules(modules: Readonly>) assertHostModuleExports(specifier, module); host.set(specifier, () => module); const exports = Object.keys(module).map((key) => `\`${key}\``); - lines.push(`- ${name}: exports ${exports.join(", ")}.`); + add(specifier, () => `exports ${exports.join(", ")}.`); } + const read = () => + summaries.map((entry) => ({ specifier: entry.specifier, summary: entry.summary() })); return { source, host, + summaries: read, get description() { - return lines.map((line) => (typeof line === "string" ? line : line())).join("\n"); + return [ + FILESYSTEM_DESCRIPTION, + ...read().map((entry) => `- \`${entry.specifier}\`: ${entry.summary}`), + COMPUTER_DESCRIPTION, + ].join("\n"); }, }; } +/** What a backend connection knows about its modules, for {@link moduleCatalog}. */ +export interface ModuleCatalogInput { + readonly parsed: ParsedModules; + /** The functions each host module's factory built on this connection. */ + readonly hostModules: ReadonlyMap; + /** Host modules whose factory built no functions on this connection. */ + readonly emptyHostModules: ReadonlySet; + /** Whether the Dynamic Worker has the Node.js built-ins. */ + readonly nodeModules: boolean; +} + +/** + * Describe every module code can import on one connection, for + * `cf:computer`. Built once when the backend connects, alongside the + * host module functions, so the two agree. + * + * @param input - The parsed modules and what their factories built. + * @returns The catalog the isolate reads. + */ +export function moduleCatalog(input: ModuleCatalogInput): ModuleCatalog { + const configured = input.parsed.summaries(); + const entries: CatalogEntry[] = [ + { + _tag: "declared", + specifier: "node:fs/promises", + summary: FILESYSTEM_SUMMARY, + types: NODE_FS_PROMISES_TYPES, + examples: [ + 'const text = await readFile("notes.md", "utf8");', + 'for (const entry of await readdir(".", { withFileTypes: true })) if (entry.isFile()) console.log(entry.name);', + ], + }, + { + _tag: "declared", + specifier: "node:fs", + summary: `the same functions as \`node:fs/promises\`, also under \`promises\`. There are no sync or callback functions.`, + types: NODE_FS_TYPES, + examples: ['import { promises as fs } from "node:fs";'], + }, + ]; + for (const { specifier, summary } of configured) { + const functions = input.hostModules.get(specifier); + if (functions !== undefined) { + const factory = input.parsed.host.get(specifier); + entries.push({ + _tag: "host", + specifier, + summary, + exports: Object.keys(functions), + types: factory?.types, + examples: factory?.examples, + }); + continue; + } + entries.push({ + _tag: "undeclared", + specifier, + summary, + note: input.emptyHostModules.has(specifier) + ? `\`${specifier}\` exports nothing on this backend: its factory built no functions when the backend connected.` + : `\`${specifier}\` is bundled JavaScript with no declarations. List its exports with \`Object.keys(await import(${JSON.stringify(specifier)}))\`.`, + }); + } + entries.push({ + _tag: "declared", + specifier: COMPUTER_MODULE, + summary: COMPUTER_SUMMARY, + types: COMPUTER_TYPES, + examples: [ + 'import * as git from "ws:git";', + 'import { docs } from "cf:computer";', + "console.log(docs(git));", + ], + }); + if (input.nodeModules) { + for (const name of NODE_MODULES) { + entries.push({ + _tag: "undeclared", + specifier: `node:${name}`, + summary: "a Node.js built-in.", + note: `\`node:${name}\` is the Node.js built-in, with the same API as in Node.js.`, + }); + } + } + const overview = [ + "Modules code can import. Pass one to `docs` for its TypeScript declarations and examples.", + "", + input.parsed.description, + ...(input.nodeModules ? [NODE_MODULES_DESCRIPTION] : []), + ].join("\n"); + return buildModuleCatalog(overview, entries); +} + /** * Check the functions a host module exports. * @@ -193,6 +326,8 @@ export interface BuildModuleGraphOptions { emptyHostModules?: ReadonlySet; /** Whether the Dynamic Worker has the Node.js built-ins. */ nodeModules: boolean; + /** What `cf:computer` tells code about every module, from {@link moduleCatalog}. */ + catalog: ModuleCatalog; maxSourceBytes: number; maxCapabilityBytes: number; maxModules?: number; @@ -232,6 +367,10 @@ export function prepareSourceModules( const edits: ImportSite[] = []; for (const site of sites) { const imported = site.specifier; + if (imported === COMPUTER_MODULE) { + edits.push({ ...site, specifier: `./${imported}` }); + continue; + } if (imported.startsWith("ws:")) { if (!hostModules.has(imported)) { throw new Error( @@ -265,6 +404,7 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { [CAPABILITIES_MODULE]: capabilitiesModule(options.maxCapabilityBytes), }); const seen = new Set(); + const hostImports = new Set(); let totalBytes = new TextEncoder().encode(options.source).byteLength; const maxModules = options.maxModules ?? 128; const maxDepth = options.maxDepth ?? 32; @@ -284,9 +424,11 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { const specifier = site.specifier; if (BUILT_IN_MODULES.some((builtIn) => builtIn === specifier)) continue; if ( + specifier === COMPUTER_MODULE || options.hostModules.has(specifier) || Object.hasOwn(options.configuredModules, specifier) ) { + if (options.hostModules.has(specifier)) hostImports.add(specifier); edits.push({ ...site, specifier: relativeSpecifier(name, storedName(specifier)) }); continue; } @@ -297,9 +439,14 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { if (node !== specifier) edits.push({ ...site, specifier: node }); continue; } - if (specifier === CAPABILITIES_MODULE) { + if (specifier === CAPABILITIES_MODULE || specifier === INTROSPECTION_MODULE) { throw new Error(`Module ${JSON.stringify(specifier)} is reserved for Workspace internals.`); } + if (specifier.startsWith("cf:")) { + throw new Error( + `Module ${JSON.stringify(specifier)} is not a built-in module. The only one is "${COMPUTER_MODULE}".`, + ); + } if (options.emptyHostModules?.has(specifier)) { throw new Error( `Module ${JSON.stringify(specifier)} exports nothing on this backend: its factory built no functions when the backend connected.`, @@ -307,7 +454,7 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { } if (specifier.startsWith("ws:")) { throw new Error( - `Module ${JSON.stringify(specifier)} is not configured. Add it to the backend's modules option.`, + `Module ${JSON.stringify(specifier)} is not configured. Add it to the backend's modules option. ${LIST_MODULES}`, ); } if (specifier.startsWith(".") || specifier.startsWith("/")) { @@ -340,14 +487,14 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { continue; } throw new Error( - `Module ${JSON.stringify(specifier)} is not configured for the worker-javascript backend.`, + `Module ${JSON.stringify(specifier)} is not configured for the worker-javascript backend. ${LIST_MODULES}`, ); } modules[name] = rewriteImports(source, edits); } - assertDefaultExport(options.source); - await visit(entryPath, options.source, 0); + const entry = entrySource(options.source); + await visit(entryPath, entry, 0); // node:* specifiers are resolved by name in both registries, so they // keep their exact keys. @@ -355,32 +502,97 @@ export async function buildModuleGraph(options: BuildModuleGraphOptions) { modules["node:fs"] = { js: nodeFsModule() }; const toCapabilities = relativeSpecifier(storedName("module"), CAPABILITIES_MODULE); + const toIntrospection = relativeSpecifier(storedName("module"), INTROSPECTION_MODULE); + modules[INTROSPECTION_MODULE] = { js: introspectionModuleSource(options.catalog) }; + modules[storedName(COMPUTER_MODULE)] = { js: computerModuleSource(toIntrospection) }; for (const [specifier, functions] of options.hostModules) { modules[storedName(specifier)] = { - js: hostModule(toCapabilities, specifier, Object.keys(functions)), + js: hostModule(toCapabilities, toIntrospection, specifier, Object.keys(functions)), }; } for (const [specifier, source] of Object.entries(options.configuredModules)) { modules[storedName(specifier)] = { js: source }; } - return { entryName, modules }; + return { entryName, modules, hostImports: [...hostImports] }; } -// The run's result comes from the entry module's default export, so a -// module without one would complete with nothing to show. Fail before -// loading it, and name any other exports, since a model that writes -// `export function main` meant one of them. -function assertDefaultExport(source: string): void { - const ast = parse(source, { ecmaVersion: "latest", sourceType: "module" }); +// The run's result comes from the entry module's default export. +// +// A module with no exports at all is a script, and runs as the body of +// the default export: its imports stay at the top, and everything else +// moves into `export default async function () { ... }`. Top-level +// `await` and `return` then work, and so does I/O, which the runtime +// refuses while a module evaluates. The imports join the function's +// opening on the first line, so a stack trace's line numbers match the +// code as it was written. +// +// A module with named exports but no default would complete with +// nothing to show, so it fails before loading, naming the exports, +// since a model that writes `export function main` meant one of them. +function entrySource(source: string): string { + const ast = parse(source, { + ecmaVersion: "latest", + sourceType: "module", + allowReturnOutsideFunction: true, + }); + if (!ast.body.some((node) => node.type.startsWith("Export"))) return asScript(source, ast.body); + assertDefaultExport(ast.body); + return source; +} + +type ProgramStatement = ReturnType["body"][number]; + +function asScript(source: string, body: readonly ProgramStatement[]): string { + const imports: string[] = []; + let rest = source; + for (const node of body) { + if (node.type !== "ImportDeclaration") continue; + imports.push(importStatement(node)); + const original = source.slice(node.start, node.end); + rest = `${rest.slice(0, node.start)}${original.replace(/[^\n]/g, " ")}${rest.slice(node.end)}`; + } + return `${imports.join(" ")} export default async function () {${rest}\n}\n`; +} + +// An import rebuilt on one line, without any comments it held. +function importStatement(node: Extract): string { + const from = JSON.stringify(node.source.value); + const attributes = + node.attributes.length === 0 + ? "" + : ` with { ${node.attributes + .map((attribute) => `${nameOf(attribute.key)}: ${JSON.stringify(attribute.value.value)}`) + .join(", ")} }`; + if (node.specifiers.length === 0) return `import ${from}${attributes};`; + const parts: string[] = []; + const named: string[] = []; + for (const specifier of node.specifiers) { + if (specifier.type === "ImportDefaultSpecifier") parts.push(specifier.local.name); + else if (specifier.type === "ImportNamespaceSpecifier") + parts.push(`* as ${specifier.local.name}`); + else named.push(`${nameOf(specifier.imported)} as ${specifier.local.name}`); + } + if (named.length > 0) parts.push(`{ ${named.join(", ")} }`); + return `import ${parts.join(", ")} from ${from}${attributes};`; +} + +function nameOf(node: { type: string; name?: string; value?: unknown }): string { + return node.type === "Identifier" ? String(node.name) : JSON.stringify(node.value); +} + +function assertDefaultExport(body: readonly ProgramStatement[]): void { const named: string[] = []; - for (const node of ast.body) { + for (const node of body) { if (node.type === "ExportDefaultDeclaration") return; + if (node.type === "ExportAllDeclaration") { + if (node.exported !== null && node.exported !== undefined) named.push(nameOf(node.exported)); + continue; + } if (node.type !== "ExportNamedDeclaration") continue; for (const specifier of node.specifiers) { - const exported = specifier.exported; - const name = exported.type === "Identifier" ? exported.name : String(exported.value); - if (name === "default") return; + const name = nameOf(specifier.exported); + if (name === "default" || name === '"default"') return; named.push(name); } const declaration = node.declaration; @@ -395,6 +607,7 @@ function assertDefaultExport(source: string): void { const shape = 'Put the work in `export default async function (input) { ... }`, or re-export one with `export { default } from "./main.js"`.'; if (named.length === 0) { + // Only `export * from`, which never re-exports a default. throw new Error(`The module has no default export, so there is nothing to run. ${shape}`); } const list = named.map((name) => `\`${name}\``); @@ -508,15 +721,19 @@ function directoryName(name: string) { } function isInternalModuleName(name: string) { + const basename = name.split("/").at(-1) ?? ""; return ( name === CAPABILITIES_MODULE || + name === INTROSPECTION_MODULE || name === RUNNER_MODULE || name === ENTRY_BASENAME || name === MODULES_DIRECTORY || name.startsWith(`${MODULES_DIRECTORY}/`) || name.endsWith(`/${CAPABILITIES_MODULE}`) || + name.endsWith(`/${INTROSPECTION_MODULE}`) || name.endsWith(`/${RUNNER_MODULE}`) || - name.split("/").at(-1)?.startsWith("ws:") === true + basename.startsWith("ws:") || + basename.startsWith("cf:") ); } @@ -594,12 +811,20 @@ function capabilitiesModule(maxCapabilityBytes: number) { // Exports go through `export { local as name }` rather than // `export const name`, so a reserved word such as `delete` still works -// as an export name. -function hostModule(capabilitiesImport: string, specifier: string, names: readonly string[]) { +// as an export name. Each stub registers with the introspection +// module, which names it and makes it print its declaration. +function hostModule( + capabilitiesImport: string, + introspectionImport: string, + specifier: string, + names: readonly string[], +) { const namespace = JSON.stringify(`host/${specifier}`); + const module = JSON.stringify(specifier); return ` import { call } from ${JSON.stringify(capabilitiesImport)}; - ${names.map((name, index) => `const fn${index} = (...args) => call(${namespace}, ${JSON.stringify(name)}, args);`).join("\n")} + import { hostExport } from ${JSON.stringify(introspectionImport)}; + ${names.map((name, index) => `const fn${index} = hostExport(${module}, ${JSON.stringify(name)}, (...args) => call(${namespace}, ${JSON.stringify(name)}, args));`).join("\n")} export { ${names.map((name, index) => `fn${index} as ${name}`).join(", ")} }; `; } diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts index c6922032..690cc8d8 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -1049,6 +1049,22 @@ describe("WorkerJavaScriptBackend", () => { expect(backend.description).toContain("- `ws:plain`: a host module."); }); + it("names cf:computer among its modules", () => { + const backend = new WorkerJavaScriptBackend({ loader: throwingLoader("must not load") }); + + expect(backend.description).toContain("- `cf:computer`: how to call every other module."); + }); + + it.each(["cf:computer", "cf:other"])("refuses a configured module named %s", (specifier) => { + expect( + () => + new WorkerJavaScriptBackend({ + loader: throwingLoader("must not load"), + modules: { [specifier]: "export const x = 1;" }, + }), + ).toThrow(specifier === "cf:computer" ? /is built in/ : /cf:\* namespace/); + }); + it("lists the Node.js built-ins code can import", () => { const backend = new WorkerJavaScriptBackend({ loader: throwingLoader("must not load") }); @@ -1484,15 +1500,27 @@ describe("WorkerJavaScriptBackend", () => { ); }); - it("rejects a module with no default export before loading it", async () => { + it("points an import of a module it doesn't have at docs()", async () => { const workspace = new Workspace({ storage: new SQLiteTestStorage(), backends: [new WorkerJavaScriptBackend({ loader: throwingLoader("must not load") })], }); - await expect(workspace.runtime.exec(`const x = 1;`)).rejects.toThrow( - 'The module has no default export, so there is nothing to run. Put the work in `export default async function (input) { ... }`, or re-export one with `export { default } from "./main.js"`.', + await expect( + workspace.runtime.exec( + `import { execSync } from "node:child_process"; execSync("git status");`, + ), + ).rejects.toThrow( + 'Module "node:child_process" is not configured for the worker-javascript backend. `docs()` from cf:computer lists the modules code can import.', ); + }); + + it("rejects a module with named exports but no default before loading it", async () => { + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [new WorkerJavaScriptBackend({ loader: throwingLoader("must not load") })], + }); + await expect( workspace.runtime.exec(`export function main() {} export const a = 1, b = 2;`), ).rejects.toThrow("The module exports `main`, `a`, and `b` but no default"); @@ -1502,6 +1530,7 @@ describe("WorkerJavaScriptBackend", () => { ["a default export", `export default 1;`], ["a named default", `function main() {} export { main as default };`], ["a re-exported default", `export { default } from "./main.js";`], + ["no exports at all, as a script", `const x = 1;`], ])("runs a module with %s", async (_label, source) => { const workspace = new Workspace({ storage: new SQLiteTestStorage(), @@ -1514,6 +1543,36 @@ describe("WorkerJavaScriptBackend", () => { await expect(execution.result()).resolves.toMatchObject({ status: "completed" }); }); + it("moves a script's code into the default export, keeping its imports on top and its lines in place", async () => { + const load = completingLoader(); + const workspace = new Workspace({ + storage: new SQLiteTestStorage(), + backends: [new WorkerJavaScriptBackend({ loader: { load } })], + }); + await workspace.fs.mkdir("/workspace", { recursive: true }); + + const execution = await workspace.runtime.exec( + [ + 'import fs, { readFile as read } from "node:fs/promises";', + "import {", + " join, // a comment", + '} from "node:path";', + 'const text = await read(join("/workspace", "a.txt"), "utf8");', + "return text;", + ].join("\n"), + ); + await execution.result(); + + const modules = load.mock.calls[0]?.[0].modules ?? {}; + const entry = modules["workspace/__workspace_entry__.js"]; + const lines = String(typeof entry === "string" ? entry : entry?.js).split("\n"); + expect(lines[0]?.trimEnd()).toBe( + 'import fs, { readFile as read } from "node:fs/promises"; import { join as join } from "node:path"; export default async function () {', + ); + expect(lines[4]).toBe('const text = await read(join("/workspace", "a.txt"), "utf8");'); + expect(lines[5]).toBe("return text;"); + }); + it("rewrites an absolute import to a path relative to its importer", async () => { const load = completingLoader(); const workspace = new Workspace({ diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index 5734be12..1ac13186 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -16,10 +16,12 @@ import type { WorkspaceRuntimeValue, } from "../../runtime/types.js"; import { decodeRuntimeFrames, type RuntimeFrame } from "./frames.js"; +import type { ModuleCatalog } from "./introspection.js"; import { assertHostModuleExports, buildModuleGraph, hasNodeModules, + moduleCatalog, NODE_MODULES_DESCRIPTION, type ParsedModules, parseModules, @@ -242,7 +244,7 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { }; this.#header = [ "`command` is ECMAScript module source, run in an isolated JavaScript runtime. Relative imports resolve from `cwd` in the workspace.", - "Put the work in `export default async function (input) { ... }` and call `node:fs` and the other modules below inside it, since the module's top level can't do I/O. To run a file you've already written, re-export it: `export { default } from \"./main.js\"`.", + "Put the work in `export default async function (input) { ... }` and call `node:fs` and the other modules below inside it, since the module's top level can't do I/O. To run a file you've already written, re-export it: `export { default } from \"./main.js\"`. A module with no exports runs as the body of that function instead, so top-level `await`, I/O, and `return` work there.", ...(resolvedEgress.mode === "none" ? ["Code has no direct network access."] : []), ...(this.#options.access === "read" ? ["The workspace is read-only here."] : []), "", @@ -269,6 +271,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { readonly #host: WorkspaceModuleBackendHost; readonly #hostModuleFunctions: ReadonlyMap; readonly #emptyHostModules: ReadonlySet; + readonly #catalog: ModuleCatalog; #sourceModules: Readonly> | undefined; readonly #records = new Map(); readonly #pendingIds = new Set(); @@ -303,6 +306,12 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { } this.#hostModuleFunctions = functions; this.#emptyHostModules = empty; + this.#catalog = moduleCatalog({ + parsed: options.modules, + hostModules: functions, + emptyHostModules: empty, + nodeModules: hasNodeModules(options.compatibilityFlags), + }); host.db.run(` CREATE TABLE IF NOT EXISTS workspace_runtime_executions ( backend TEXT NOT NULL, @@ -423,6 +432,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { hostModules: this.#hostModuleFunctions, emptyHostModules: this.#emptyHostModules, nodeModules: hasNodeModules(this.#options.compatibilityFlags), + catalog: this.#catalog, maxSourceBytes: this.#options.maxSourceBytes, maxCapabilityBytes: this.#options.maxCapabilityBytes, }); @@ -470,6 +480,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { loader: this.#options.loader, modules: graph.modules, entryName: graph.entryName, + hostImports: graph.hostImports, input: inputValue, context: { env: input.env ?? {}, @@ -984,6 +995,7 @@ function startJavaScriptExecution(options: { loader: WorkspaceRuntimeLoader; modules: Record; entryName: string; + hostImports: readonly string[]; input: WorkspaceRuntimeValue; context: WorkspaceExecutionContext; bridge: WorkspaceRuntimeBridge; @@ -998,7 +1010,11 @@ function startJavaScriptExecution(options: { }): ActiveControl { const modules = { ...options.modules, - "workspace-runtime-runner.js": runtimeWorkerModule(options.entryName, options.maxStdioBytes), + "workspace-runtime-runner.js": runtimeWorkerModule( + options.entryName, + options.maxStdioBytes, + options.hostImports, + ), }; assertLoaderGraph(modules, options.maxSourceBytes); const worker = options.loader.load({ @@ -1081,10 +1097,23 @@ function startJavaScriptExecution(options: { }; } -function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { +function runtimeWorkerModule( + entryName: string, + maxStdioBytes: number, + hostImports: readonly string[], +) { + // Calling an export a host module doesn't have fails with a bare + // "is not a function". Point at where the real exports are listed. + const hint = + hostImports.length === 0 + ? "" + : `If that was a call into ${hostImports.join(" or ")}, ${hostImports + .map((specifier) => `docs(${JSON.stringify(specifier)})`) + .join(" or ")} from cf:computer lists what it exports.`; return ` import { WorkerEntrypoint } from "cloudflare:workers"; import { install, moduleScopeRefusals } from "workspace-capabilities.js"; + import { formatConsole, inspect } from "workspace-introspection.js"; // A promise nobody awaits would otherwise fail without a trace while // the run reports success. Track the rejections nothing handled, and @@ -1162,10 +1191,23 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { // surfacing as an unhandled rejection inside the isolate. The // task awaits writeChain before closing. let writeChain = Promise.resolve(); + // The entry module evaluates outside any request, where the + // runtime refuses the stream write, so output from its top level + // is held until the import settles. + let held = null; const enqueue = (frame) => { + if (held !== null) { + held.push(frame); + return; + } const bytes = encoder.encode(JSON.stringify(frame) + "\\n"); writeChain = writeChain.then(() => writer.write(bytes)).catch(() => {}); }; + const release = () => { + const frames = held ?? []; + held = null; + for (const frame of frames) enqueue(frame); + }; let stdioBytes = 0; let stdioTruncated = false; const record = (stream, text) => { @@ -1195,9 +1237,11 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { } stdioTruncated = true; }; - const consoleLine = (stream, args) => record(stream, args.map(String).join(" ") + "\\n"); + const consoleLine = (stream, args) => record(stream, formatConsole(args) + "\\n"); console.log = (...args) => consoleLine("stdout", args); console.info = (...args) => consoleLine("stdout", args); + console.debug = (...args) => consoleLine("stdout", args); + console.dir = (value) => record("stdout", inspect(value) + "\\n"); console.warn = (...args) => consoleLine("stderr", args); console.error = (...args) => consoleLine("stderr", args); nextProcess.stdout = { @@ -1240,7 +1284,13 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { const drained = host.attachOutput(output.readable); unhandled.clear(); try { - const module = await import(${JSON.stringify(entryName)}); + held = []; + let module; + try { + module = await import(${JSON.stringify(entryName)}); + } finally { + release(); + } const result = typeof module.default === "function" ? await module.default(input) : module.default ?? null; @@ -1257,8 +1307,12 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { } catch (error) { // Imports of configured and host modules were rewritten to paths // into the module directory. Name them as the code wrote them. - const message = (error instanceof Error ? error.message : String(error)) + let message = (error instanceof Error ? error.message : String(error)) .replace(/(?:file:\\/\\/\\/bundle\\/|(?:\\.\\.?\\/)+)__modules__\\//g, ""); + const hint = ${JSON.stringify(hint)}; + if (hint !== "" && error instanceof TypeError && message.includes("is not a function")) { + message += ". " + hint; + } enqueue({ name: "stderr", b64: toBase64(truncate(message) + "\\n") }); enqueue({ name: "exit", code: 1 }); } diff --git a/packages/computer/src/index.ts b/packages/computer/src/index.ts index 0de9a382..e6378877 100644 --- a/packages/computer/src/index.ts +++ b/packages/computer/src/index.ts @@ -93,6 +93,7 @@ export type { WorkspaceModuleFunction, WorkspaceModuleFunctions, WorkspaceModuleHost, + WorkspaceModuleTypes, WorkspaceRegisteredBackend, WorkspaceRuntimeAccess, WorkspaceRuntimeDisposeOptions, diff --git a/packages/computer/src/modules/artifacts.ts b/packages/computer/src/modules/artifacts.ts index c65abf39..fbd52cc1 100644 --- a/packages/computer/src/modules/artifacts.ts +++ b/packages/computer/src/modules/artifacts.ts @@ -15,6 +15,7 @@ import type { WorkspaceModuleFactory, WorkspaceModuleFunctions, WorkspaceModuleHost, + WorkspaceModuleTypes, WorkspaceRuntimeValue, } from "../runtime/types.js"; @@ -96,9 +97,67 @@ export function createArtifactsModule( }); return Object.assign(create, { description: `Git repositories stored in Cloudflare Artifacts: \`create(name)\`, \`get(name)\`, \`list()\`, \`importArtifact(name, source)\`, and \`deleteArtifact(name)\`.${allowNetwork ? "" : " Importing from a remote URL is not allowed."} \`createToken(name, scope?, ttl?)\` mints a git token ("read" by default). \`share(name, { scope?, ttl? })\` returns a clone-ready URL with a token embedded; it is a credential, so never print or return it. \`ttl\` is seconds or a duration like "15m".`, + types: artifactsTypes(allowNetwork), + examples: [ + 'const repo = await create("notes");', + 'const remote = await share("notes", { scope: "write", ttl: "15m" });', + ], }); } +function artifactsTypes(allowNetwork: boolean): WorkspaceModuleTypes { + return { + ArtifactRepo: `interface ArtifactRepo { + id: string; + name: string; + description: string | null; + defaultBranch: string; + createdAt: string; + updatedAt: string; + lastPushAt: string | null; +}`, + CreatedArtifact: `/** \`token\` is a credential for \`remote\`. Never print or return it. */ +interface CreatedArtifact { + id: string; + name: string; + description: string | null; + defaultBranch: string; + remote: string; + token: string; +}`, + create: `/** Create a git repository. */ +function create( + name: string, + options?: { readOnly?: boolean; description?: string; setDefaultBranch?: string }, +): Promise;`, + get: `/** A repository's details, including its HTTPS \`remote\`. Rejects when it does not exist. */ +function get(name: string): Promise;`, + list: "function list(): Promise;", + importArtifact: `/** Import an existing git remote as a new repository.${allowNetwork ? "" : " Not allowed here: importing from a remote URL needs network access."} */ +function importArtifact( + name: string, + source: { url: string; branch?: string; depth?: number }, + options?: { description?: string; readOnly?: boolean }, +): Promise;`, + deleteArtifact: + "/** Resolves false when the repository does not exist. */\nfunction deleteArtifact(name: string): Promise;", + createToken: `/** + * Mint a git token for a repository. \`scope\` defaults to "read". \`ttl\` is + * seconds or a duration such as "15m". \`plaintext\` is a credential. + */ +function createToken( + name: string, + scope?: "read" | "write", + ttl?: number | string, +): Promise<{ id: string; plaintext: string; scope: "read" | "write"; expiresAt: string }>;`, + share: `/** + * The repository's HTTPS remote with a fresh token in it, ready to clone or push. + * It is a credential: never print or return it. + */ +function share(name: string, options?: { scope?: "read" | "write"; ttl?: number | string }): Promise;`, + }; +} + function requireWrite(context: WorkspaceModuleCallContext, operation: string) { if (context.access !== "read-write") { throw new Error(`${operation} requires Workspace write access.`); diff --git a/packages/computer/src/modules/assets.ts b/packages/computer/src/modules/assets.ts index e085c0c6..ea7560ed 100644 --- a/packages/computer/src/modules/assets.ts +++ b/packages/computer/src/modules/assets.ts @@ -4,6 +4,7 @@ import type { WorkspaceModuleFactory, WorkspaceModuleFunctions, WorkspaceModuleHost, + WorkspaceModuleTypes, WorkspaceRuntimeValue, } from "../runtime/types.js"; @@ -54,9 +55,30 @@ export function createAssetsModule(options: AssetsModuleOptions = {}): Workspace return Object.assign(create, { description: '`publish(path, { expiresAfter?, filename?, disposition?, contentType? })` uploads a workspace file and returns a time-limited public URL. `expiresAfter` is milliseconds or a duration like "30m" or "1d"; the default is one hour and the maximum is seven days. `disposition` is "inline" or "attachment".', + types: ASSETS_TYPES, + examples: [ + 'const url = await publish("out/report.pdf", { expiresAfter: "1d", disposition: "attachment" });', + ], }); } +const ASSETS_TYPES: WorkspaceModuleTypes = { + publish: `/** + * Upload a workspace file and return a public URL that expires. + * \`expiresAfter\` is milliseconds or a duration such as "30m" or "1d". It defaults + * to one hour and is at most seven days. + */ +function publish( + path: string, + options?: { + expiresAfter?: number | string; + filename?: string; + disposition?: "inline" | "attachment"; + contentType?: string; + }, +): Promise;`, +}; + function parsePublishOptions( value: WorkspaceRuntimeValue | undefined, defaultExpiresAfter: number, diff --git a/packages/computer/src/modules/container.ts b/packages/computer/src/modules/container.ts index ab954e9f..63d59df9 100644 --- a/packages/computer/src/modules/container.ts +++ b/packages/computer/src/modules/container.ts @@ -24,6 +24,7 @@ import type { WorkspaceModuleFunction, WorkspaceModuleFunctions, WorkspaceModuleHost, + WorkspaceModuleTypes, WorkspaceRuntimeValue, } from "../runtime/types.js"; import type { ExecSyncResult } from "../shell.js"; @@ -173,9 +174,45 @@ export function createContainerModule( context.signal.removeEventListener("abort", kill); } }; - return Object.assign(create, { description: DESCRIPTION }); + return Object.assign(create, { + description: DESCRIPTION, + types: CONTAINER_TYPES, + examples: [ + 'const { exitCode, stdout, stderr } = await exec("npm test", { cwd: "/workspace" });', + 'const versions = await exec("node --version && npm --version");', + ], + }); } +const CONTAINER_TYPES: WorkspaceModuleTypes = { + TruncatedOutput: `/** How a long stream was cut. When "saved", \`path\` is a workspace file with all of it. */ +type TruncatedOutput = + | { status: "saved"; path: string; totalBytes: number; totalLines: number; firstLine: number } + | { status: "not-saved"; reason: string; totalBytes: number; totalLines: number; firstLine: number };`, + ContainerExecResult: `interface ContainerExecResult { + /** A non-zero code is returned, not thrown. */ + exitCode: number; + /** The end of the output when it was long; see \`truncated\`. */ + stdout: string; + stderr: string; + /** Present when output was cut. */ + truncated?: { stdout?: TruncatedOutput; stderr?: TruncatedOutput }; + /** + * Whether the container's file changes reached the workspace. "pending" + * means they have not yet. \`skipped\` lists up to 100 paths the workspace refused. + */ + sync: { status: "complete" | "pending"; skipped: string[]; skippedCount: number; error?: string }; +}`, + exec: `/** + * Run a shell command in the container, which shares this workspace's files, + * and wait for it to finish. The first call can take a while as the container starts. + */ +function exec( + command: string, + options?: { cwd?: string; env?: Record; stdin?: string; timeoutMs?: number }, +): Promise;`, +}; + const DESCRIPTION = [ "Runs shell commands in a full Linux container that shares this workspace's files.", "Use it for npm, node, python, package managers, and native binaries. The container can take a while to start on first use.", diff --git a/packages/computer/src/modules/git.ts b/packages/computer/src/modules/git.ts index 33b3380b..f0538127 100644 --- a/packages/computer/src/modules/git.ts +++ b/packages/computer/src/modules/git.ts @@ -14,6 +14,7 @@ import type { WorkspaceModuleFactory, WorkspaceModuleFunctions, WorkspaceModuleHost, + WorkspaceModuleTypes, WorkspaceRuntimeValue, } from "../runtime/types.js"; @@ -34,7 +35,9 @@ export interface GitModuleOptions { * * It exports `clone`, `diff`, `status`, `log`, and `cli`. Each takes the * same options object as the matching `GitClient` method, with `dir` or - * `cwd` resolved against the backend root. + * `cwd` resolved against the backend root. A call that passes anything + * other than one options object is refused with the expected shape, so + * code that guessed the signature learns it from the error. * * @param options - Whether network commands are allowed. * @returns The module to pass as `modules["ws:git"]`. @@ -48,47 +51,209 @@ export function createGitModule(options: GitModuleOptions = {}): WorkspaceModule }; const create = (host: WorkspaceModuleHost): WorkspaceModuleFunctions => ({ - async clone([value], context) { + async clone(args, context) { requireWrite(context, "Git clone"); + const input = optionsObject("clone", args, { required: true }); + if (typeof input.url !== "string" || input.url === "") { + throw new TypeError(`ws:git clone needs a url. ${USAGE.clone}`); + } requireNetwork("Git clone"); // SAFETY: The isolate's options object passes through to the Git client, as it did when ws:git was built in. The client checks its own options; only the path is rewritten here. return host.git.clone( - (await withDir(value, context, true)) as unknown as Parameters[0], + (await withDir(input, context, true)) as unknown as Parameters[0], ); }, - async diff([value], context) { + async diff(args, context) { + const input = optionsObject("diff", args); // SAFETY: As for clone. - return host.git.diff((await withDir(value, context)) as Parameters[0]); + return host.git.diff((await withDir(input, context)) as Parameters[0]); }, - async status([value], context) { + async status(args, context) { + const input = optionsObject("status", args); // SAFETY: As for clone. - return host.git.status((await withDir(value, context)) as Parameters[0]); + return host.git.status((await withDir(input, context)) as Parameters[0]); }, - async log([value], context) { + async log(args, context) { + const input = optionsObject("log", args); // SAFETY: As for clone. - return host.git.log((await withDir(value, context)) as Parameters[0]); + return host.git.log((await withDir(input, context)) as Parameters[0]); }, - async cli([value], context) { + async cli(args, context) { requireWrite(context, "Git CLI"); - // SAFETY: As for clone. - const input = (value ?? {}) as unknown as Parameters[0]; - const { argv, cwd } = leadingDirectory(input.argv ?? [], input.cwd ?? "."); - assertSafeCliArguments(argv); - if (argv.some((argument) => NETWORK_COMMANDS.has(argument.toLowerCase()))) { + const input = optionsObject("cli", args, { required: true }); + const argv = cliArguments(input.argv); + const directory = input.cwd ?? "."; + if (typeof directory !== "string") { + throw new TypeError(`ws:git cli cwd must be a string. ${USAGE.cli}`); + } + const leading = leadingDirectory(argv, directory); + assertSafeCliArguments(leading.argv); + if (leading.argv.some((argument) => NETWORK_COMMANDS.has(argument.toLowerCase()))) { requireNetwork("Git CLI network command"); } + // SAFETY: As for clone. argv and cwd were checked above. + const rest = input as unknown as Omit[0], "argv" | "cwd">; return host.git.cli({ - ...input, - argv, - cwd: await context.resolvePath(cwd, { allowMissing: true }), + ...rest, + argv: leading.argv, + cwd: await context.resolvePath(leading.cwd, { allowMissing: true }), }); }, }); return Object.assign(create, { - description: `The workspace's Git repository tools: \`status({ dir })\`, \`diff({ dir })\`, \`log({ dir, depth })\`, \`clone({ url, dir })\`, and \`cli({ argv, cwd })\` for any other git subcommand, including a leading \`-C \`.${allowNetwork ? "" : " Network commands such as clone, fetch, and push are not allowed."}`, + description: `The workspace's Git repository: \`status({ dir })\`, \`diff({ dir, ref })\`, \`log({ dir, depth })\`, \`clone({ url, dir })\`, and \`cli({ argv, cwd })\` for any other git subcommand. \`argv\` leaves out "git": \`cli({ argv: ["commit", "-m", "Fix"] })\`.${allowNetwork ? "" : " Network commands such as clone, fetch, and push are not allowed."}`, + types: gitTypes(allowNetwork), + examples: [ + 'const { stdout } = await cli({ argv: ["log", "--oneline", "-5"] });', + 'await cli({ argv: ["add", "."] });', + 'await cli({ argv: ["commit", "-m", "Update notes"] });', + "const changed = await status();", + ], }); } +// How each export is called, for errors from a call that guessed. +const USAGE = { + clone: 'Call it as clone({ url: "https://github.com/owner/repo", dir?: "repo" }).', + diff: 'Call it as diff({ dir?: "repo", ref?: "HEAD", to?: "main", paths?: ["src"] }).', + status: 'Call it as status({ dir?: "repo" }).', + log: 'Call it as log({ dir?: "repo", ref?: "HEAD", depth?: 10 }).', + cli: 'Call it as cli({ argv: ["status"], cwd?: "repo" }), where argv leaves out "git".', +} as const; + +function gitTypes(allowNetwork: boolean): WorkspaceModuleTypes { + const network = allowNetwork + ? "" + : "\n * Network subcommands (clone, fetch, pull, push, ls-remote, submodule) are not allowed here."; + return { + GitCliResult: `interface GitCliResult { + stdout: string; + stderr: string; + /** 0 on success. A failing command resolves with a non-zero code; it does not reject. */ + exitCode: number; +}`, + StatusEntry: `/** + * One changed path. \`index\` is the staged change against HEAD and + * \`worktree\` the unstaged one: " " unchanged, "A" added, "M" modified, + * "D" deleted, "?" untracked. + */ +interface StatusEntry { + path: string; + index: " " | "A" | "M" | "D"; + worktree: " " | "A" | "M" | "D" | "?"; +}`, + GitPerson: `interface GitPerson { + name: string; + email: string; + /** Seconds since the epoch. */ + timestamp: number; + /** Minutes east of UTC. */ + timezoneOffset: number; +}`, + CommitView: `interface CommitView { + oid: string; + message: string; + tree: string; + parent: string[]; + author: GitPerson; + committer: GitPerson; +}`, + clone: `/** + * Clone an HTTPS repository into \`dir\`, which defaults to the backend root. + * \`depth\` makes a shallow clone; \`headers\` can carry an Authorization header.${ + allowNetwork ? "" : "\n * Not allowed here: cloning needs network access." + } + */ +function clone(options: { + url: string; + dir?: string; + ref?: string; + depth?: number; + singleBranch?: boolean; + noTags?: boolean; + paths?: string[]; + headers?: Record; +}): Promise;`, + diff: `/** + * A unified diff. Without \`to\`, between \`ref\` (default HEAD) and the + * working tree. With \`to\`, between the two commits. + */ +function diff(options?: { dir?: string; ref?: string; to?: string; paths?: string[] }): Promise;`, + status: `/** The changed paths of the repository at \`dir\`, which defaults to the backend root. */ +function status(options?: { dir?: string }): Promise;`, + log: `/** Commits from \`ref\` (default HEAD) backwards, newest first, at most \`depth\` of them. */ +function log(options?: { dir?: string; ref?: string; depth?: number }): Promise;`, + cli: `/** + * Run a git subcommand, as the git command line would. \`argv\` leaves + * out "git": \`["log", "--oneline", "-5"]\`. \`cwd\` defaults to the backend + * root, and a leading \`["-C", path]\` works too.${network} + */ +function cli(input: { argv: string[]; cwd?: string; env?: Record }): Promise;`, + }; +} + +// Every export takes one options object. A string or an array in its +// place is the usual guess, and passing it on would run the command +// with no arguments, so refuse it and show the shape instead. +function optionsObject( + operation: keyof typeof USAGE, + args: readonly WorkspaceRuntimeValue[], + options: { readonly required?: boolean } = {}, +): Record { + const [value, ...rest] = args; + if (value === undefined && options.required !== true) return {}; + if ( + rest.length > 0 || + value === undefined || + value === null || + typeof value !== "object" || + Array.isArray(value) + ) { + const got = args.map((arg) => JSON.stringify(arg) ?? "undefined").join(", "); + throw new TypeError( + `ws:git ${operation} takes one options object, but got (${got}). ${USAGE[operation]}${SEE_DOCS}`, + ); + } + // An option the export does not take would be ignored, and a guess + // such as log({ cwd }) would then run somewhere else. Name the ones + // it does take. + const allowed = OPTION_KEYS[operation]; + const unknown = Object.keys(value).filter((key) => !allowed.includes(key)); + if (unknown.length > 0) { + throw new TypeError( + `ws:git ${operation} has no option ${unknown.map((key) => JSON.stringify(key)).join(" or ")}. Its options are ${allowed.join(", ")}.${SEE_DOCS}`, + ); + } + return value; +} + +const SEE_DOCS = ' docs("ws:git") from cf:computer shows every export\'s declaration.'; + +// The options each export takes, as its declaration lists them. +const OPTION_KEYS: Readonly> = { + clone: ["url", "dir", "ref", "depth", "singleBranch", "noTags", "paths", "headers"], + diff: ["dir", "ref", "to", "paths"], + status: ["dir"], + log: ["dir", "ref", "depth"], + cli: ["argv", "cwd", "env"], +}; + +function cliArguments(value: WorkspaceRuntimeValue | undefined): string[] { + if (!Array.isArray(value) || !value.every(isString)) { + throw new TypeError(`ws:git cli argv must be an array of strings. ${USAGE.cli}${SEE_DOCS}`); + } + if (value[0] === "git") { + throw new TypeError( + `ws:git cli argv leaves out "git": pass ${JSON.stringify(value.slice(1))}, not ${JSON.stringify(value)}.`, + ); + } + return value; +} + +function isString(value: WorkspaceRuntimeValue): value is string { + return typeof value === "string"; +} + function requireWrite(context: WorkspaceModuleCallContext, operation: string) { if (context.access !== "read-write") { throw new Error(`${operation} requires Workspace write access.`); @@ -96,11 +261,10 @@ function requireWrite(context: WorkspaceModuleCallContext, operation: string) { } async function withDir( - value: WorkspaceRuntimeValue | undefined, + options: Record, context: WorkspaceModuleCallContext, allowMissing = false, ): Promise> { - const options = value !== null && typeof value === "object" && !Array.isArray(value) ? value : {}; return { ...options, dir: await context.resolvePath(typeof options.dir === "string" ? options.dir : ".", { @@ -126,9 +290,9 @@ function leadingDirectory(argv: string[], cwd: string): { argv: string[]; cwd: s // Any other path override would let a command escape the confined // directory. -function assertSafeCliArguments(argv: string[] | undefined) { +function assertSafeCliArguments(argv: readonly string[]) { if ( - argv?.some( + argv.some( (argument) => argument === "-C" || argument.startsWith("-C") || diff --git a/packages/computer/src/modules/json-schema-type.test.ts b/packages/computer/src/modules/json-schema-type.test.ts new file mode 100644 index 00000000..b05b19cd --- /dev/null +++ b/packages/computer/src/modules/json-schema-type.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { acceptsNothing, jsonSchemaType } from "./json-schema-type.js"; + +describe("jsonSchemaType", () => { + it.each([ + [{ type: "string" }, "string"], + [{ type: "integer" }, "number"], + [{ type: ["string", "null"] }, "string | null"], + [{ enum: ["a", "b", 1] }, '"a" | "b" | 1'], + [{ const: true }, "true"], + [{ anyOf: [{ type: "string" }, { type: "number" }] }, "string | number"], + [ + { allOf: [{ type: "object", properties: { a: { type: "string" } }, required: ["a"] }] }, + "{ a: string; }", + ], + [{ type: "array", items: { type: "string" } }, "string[]"], + [ + { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } }, + "Array", + ], + [{ type: "array", items: [{ type: "string" }, { type: "number" }] }, "[string, number]"], + [{ type: "object" }, "Record"], + [{ type: "object", additionalProperties: false }, "{}"], + [{ type: "object", additionalProperties: { type: "number" } }, "{ [key: string]: number; }"], + [{ type: "object", properties: { "a-b": { type: "string" } } }, '{ "a-b"?: string; }'], + [{ not: {} }, "unknown"], + ["not a schema", "unknown"], + ])("writes %j as %s", (schema, expected) => { + expect(jsonSchemaType(schema)).toBe(expected); + }); + + it("puts each documented property on its own line", () => { + expect( + jsonSchemaType({ + type: "object", + properties: { + path: { type: "string", description: "Where to read." }, + limit: { type: "number" }, + }, + required: ["path"], + }), + ).toBe("{\n /** Where to read. */\n path: string;\n limit?: number;\n}"); + }); + + it("follows local refs and stops at a cycle", () => { + expect( + jsonSchemaType({ + $defs: { node: { type: "object", properties: { next: { $ref: "#/$defs/node" } } } }, + $ref: "#/$defs/node", + }), + ).toBe("{ next?: unknown; }"); + }); + + it("keeps a comment from closing early", () => { + expect( + jsonSchemaType({ + type: "object", + properties: { a: { type: "string", description: "x */ y" } }, + }), + ).toContain("/** x * / y */"); + }); +}); + +describe("acceptsNothing", () => { + it("is true unless the schema requires a property", () => { + expect(acceptsNothing({ type: "object", properties: { a: { type: "string" } } })).toBe(true); + expect(acceptsNothing({ type: "object", required: ["a"] })).toBe(false); + }); +}); diff --git a/packages/computer/src/modules/json-schema-type.ts b/packages/computer/src/modules/json-schema-type.ts new file mode 100644 index 00000000..f12b04ab --- /dev/null +++ b/packages/computer/src/modules/json-schema-type.ts @@ -0,0 +1,173 @@ +// A JSON Schema written as a TypeScript type, for declarations a model +// reads. It covers what tool input schemas use: objects, arrays, +// primitives, enums, constants, unions, intersections, and local +// `$ref`s. Anything else becomes `unknown`, which is honest rather than +// wrong. + +const MAX_DEPTH = 8; +const MAX_INLINE_LENGTH = 72; + +type Schema = Readonly>; + +/** + * Write a JSON Schema as a TypeScript type. + * + * @param schema - The schema. A value that is not a schema object becomes `unknown`. + * @returns The type, formatted over several lines when it is long. + */ +export function jsonSchemaType(schema: unknown): string { + if (!isSchema(schema)) return "unknown"; + return typeOf(schema, { root: schema, refs: new Set(), depth: 0 }); +} + +/** + * Whether every property of an object schema is optional, so a + * function taking it can be called with nothing. + * + * @param schema - The schema. + * @returns True unless the schema requires at least one property. + */ +export function acceptsNothing(schema: unknown): boolean { + if (!isSchema(schema)) return true; + const required = schema.required; + return !Array.isArray(required) || required.length === 0; +} + +interface Context { + readonly root: Schema; + readonly refs: ReadonlySet; + readonly depth: number; +} + +function typeOf(schema: Schema, context: Context): string { + if (context.depth > MAX_DEPTH) return "unknown"; + const next = { ...context, depth: context.depth + 1 }; + + if (typeof schema.$ref === "string") { + const ref = schema.$ref; + const target = context.refs.has(ref) ? undefined : resolveRef(context.root, ref); + return target === undefined + ? "unknown" + : typeOf(target, { ...next, refs: new Set([...context.refs, ref]) }); + } + if ("const" in schema) return literal(schema.const); + if (Array.isArray(schema.enum)) return union(schema.enum.map(literal)); + const oneOf = schemas(schema.oneOf) ?? schemas(schema.anyOf); + if (oneOf !== undefined) return union(oneOf.map((part) => typeOf(part, next))); + const allOf = schemas(schema.allOf); + if (allOf !== undefined) return allOf.map((part) => typeOf(part, next)).join(" & "); + + if (Array.isArray(schema.type)) { + return union( + schema.type + .filter((type): type is string => typeof type === "string") + .map((type) => typeOf({ ...schema, type }, context)), + ); + } + switch (schema.type) { + case "string": + return "string"; + case "number": + case "integer": + return "number"; + case "boolean": + return "boolean"; + case "null": + return "null"; + case "array": + return arrayType(schema, next); + case "object": + return objectType(schema, next); + default: + if (isSchema(schema.properties)) return objectType(schema, next); + if (schema.items !== undefined) return arrayType(schema, next); + return "unknown"; + } +} + +function objectType(schema: Schema, context: Context): string { + const properties = isSchema(schema.properties) ? schema.properties : {}; + const required = new Set( + Array.isArray(schema.required) + ? schema.required.filter((name): name is string => typeof name === "string") + : [], + ); + const members: string[] = []; + let documented = false; + for (const [name, value] of Object.entries(properties)) { + if (!isSchema(value)) continue; + const description = typeof value.description === "string" ? value.description : undefined; + if (description !== undefined) { + documented = true; + members.push(`/** ${comment(description)} */`); + } + members.push( + `${propertyName(name)}${required.has(name) ? "" : "?"}: ${typeOf(value, context)};`, + ); + } + const extra = schema.additionalProperties; + if (isSchema(extra)) members.push(`[key: string]: ${typeOf(extra, context)};`); + else if (extra === true) members.push("[key: string]: unknown;"); + // An object schema with no properties takes any keys unless it says otherwise. + if (members.length === 0) return extra === false ? "{}" : "Record"; + const inline = `{ ${members.join(" ")} }`; + if (!documented && inline.length <= MAX_INLINE_LENGTH && !inline.includes("\n")) return inline; + return `{\n${members.map((member) => indent(member)).join("\n")}\n}`; +} + +function arrayType(schema: Schema, context: Context): string { + const items = schema.items; + if (Array.isArray(items)) { + return `[${items.map((item) => (isSchema(item) ? typeOf(item, context) : "unknown")).join(", ")}]`; + } + if (!isSchema(items)) return "unknown[]"; + const item = typeOf(items, context); + return /[|&\n]/.test(item) ? `Array<${item}>` : `${item}[]`; +} + +function resolveRef(root: Schema, ref: string): Schema | undefined { + if (ref === "#") return root; + if (!ref.startsWith("#/")) return undefined; + let current: unknown = root; + for (const part of ref.slice(2).split("/")) { + const key = part.replace(/~1/g, "/").replace(/~0/g, "~"); + if (!isSchema(current)) return undefined; + current = current[key]; + } + return isSchema(current) ? current : undefined; +} + +function schemas(value: unknown): Schema[] | undefined { + return Array.isArray(value) && value.length > 0 && value.every(isSchema) ? value : undefined; +} + +function literal(value: unknown): string { + if (value === null || typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return typeof value === "string" ? JSON.stringify(value) : "unknown"; +} + +function union(types: readonly string[]): string { + const unique = [...new Set(types)]; + return unique.length === 0 ? "never" : unique.join(" | "); +} + +function propertyName(name: string): string { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name); +} + +function comment(text: string): string { + return text.replace(/\*\//g, "* /").replace(/\s+/g, " ").trim(); +} + +function indent(text: string): string { + return text + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); +} + +function isSchema(value: unknown): value is Schema { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/packages/computer/src/modules/tools.test.ts b/packages/computer/src/modules/tools.test.ts index f8b9709a..d308279d 100644 --- a/packages/computer/src/modules/tools.test.ts +++ b/packages/computer/src/modules/tools.test.ts @@ -219,4 +219,47 @@ describe("createToolBindings", () => { expect(factory.description).toContain("The agent's own tools, callable from code."); expect(factory.description).not.toContain("It exports"); }); + it("declares each tool from its description and input schema, under its export name", () => { + const types = createToolBindings([ + { + ...recording("list-files").binding, + description: "List files.", + inputSchema: { + type: "object", + properties: { dir: { type: "string" } }, + required: ["dir"], + }, + }, + { ...recording("status").binding, inputSchema: { type: "object", properties: {} } }, + recording("raw").binding, + recording("exec").binding, + ]).types; + + expect(types?.list_files).toBe( + "/** List files. */\nfunction list_files(input: { dir: string; }): Promise;", + ); + expect(types?.status).toBe( + "function status(input?: Record): Promise;", + ); + expect(types?.raw).toBe("function raw(input?: Record): Promise;"); + expect(types?.ToolResult).toContain("interface ToolResult"); + expect(types).not.toHaveProperty("exec"); + }); + + it("writes a description over several lines as a block comment", () => { + const types = createToolBindings([ + { ...recording("read").binding, description: "Read a file.\n\nText only." }, + ]).types; + + expect(types?.read).toBe( + "/**\n * Read a file.\n *\n * Text only.\n */\nfunction read(input?: Record): Promise;", + ); + }); + + it("does not read a single-use iterable to declare it", () => { + function* tools() { + yield recording("read").binding; + } + expect(createToolBindings(tools()).types).toBeUndefined(); + }); }); diff --git a/packages/computer/src/modules/tools.ts b/packages/computer/src/modules/tools.ts index 2269a645..807e43a6 100644 --- a/packages/computer/src/modules/tools.ts +++ b/packages/computer/src/modules/tools.ts @@ -24,12 +24,22 @@ import type { WorkspaceModuleFunction, WorkspaceModuleFunctions, WorkspaceModuleHost, + WorkspaceModuleTypes, WorkspaceRuntimeValue, } from "../runtime/types.js"; +import { acceptsNothing, jsonSchemaType } from "./json-schema-type.js"; /** One tool, as code calls it. */ export interface ToolBinding { readonly name: string; + /** What the tool does, as the model reads it. Shown with the export's declaration. */ + readonly description?: string; + /** + * The JSON Schema of the tool's one object of arguments. It becomes + * the export's TypeScript declaration, which code reads with + * `cf:computer`. Without it, the export takes any object. + */ + readonly inputSchema?: object; /** * The tool only reads. On a read-only backend, calls to any tool * without it are refused before the tool runs. @@ -112,29 +122,90 @@ export function createToolBindings( } return functions; }; - return Object.defineProperty(create, "description", { - enumerable: true, - get: () => options.description ?? describe(exportNames(bindings, exclude), [...exclude]), + return Object.defineProperties(create, { + description: { + enumerable: true, + get: () => options.description ?? describe(exportNames(bindings, exclude), [...exclude]), + }, + types: { enumerable: true, get: () => declarations(bindings, exclude) }, + examples: { enumerable: true, get: () => examples(bindings, exclude) }, }) as WorkspaceModuleFactory; } -// The names code can import, for the description. A function of tools -// is called again, so the list is current whenever the backend -// describes itself. Any other iterable may be single-use, so it is -// left for the connection to read, and a function that throws leaves -// the list out rather than the whole description. -function exportNames(bindings: ToolBindings, exclude: ReadonlySet): string[] | undefined { +const RESULT_DECLARATION = `/** What every tool resolves with. A tool that throws rejects instead. */ +interface ToolResult { + /** The tool's output, as the model would read it. */ + text: string; + /** Whether the tool reported a failure. */ + isError: boolean; + /** The tool's structured result, or null. */ + details: unknown; + structuredContent?: unknown; +}`; + +// Each export's declaration, from the tool's description and input +// schema. Read as the export names are, so a list a function returns +// is current, and a single-use iterable is left for the connection. +function declarations( + bindings: ToolBindings, + exclude: ReadonlySet, +): WorkspaceModuleTypes | undefined { + const list = readBindings(bindings, exclude); + if (list === undefined) return undefined; + const types: Record = { ToolResult: RESULT_DECLARATION }; + for (const binding of list) { + const name = exportName(binding.name); + const input = + binding.inputSchema === undefined + ? "Record" + : jsonSchemaType(binding.inputSchema); + const optional = binding.inputSchema === undefined || acceptsNothing(binding.inputSchema); + const doc = docComment(binding.description); + types[name] = + `${doc}function ${name}(input${optional ? "?" : ""}: ${input}): Promise;`; + } + return types; +} + +function docComment(description: string | undefined): string { + const lines = (description ?? "").replace(/\*\//g, "* /").trim().split("\n"); + if (lines.length === 1 && lines[0] === "") return ""; + if (lines.length === 1) return `/** ${lines[0]} */\n`; + return `/**\n${lines.map((line) => ` * ${line}`.trimEnd()).join("\n")}\n */\n`; +} + +function examples(bindings: ToolBindings, exclude: ReadonlySet): string[] | undefined { + const first = readBindings(bindings, exclude)?.[0]; + if (first === undefined) return undefined; + const name = exportName(first.name); + return [ + `const { text, isError, details } = await ${name}({ /* the tool's arguments */ });`, + "if (isError) throw new Error(text);", + ]; +} + +function readBindings( + bindings: ToolBindings, + exclude: ReadonlySet, +): ToolBinding[] | undefined { if (typeof bindings !== "function" && !Array.isArray(bindings)) return undefined; try { const list = typeof bindings === "function" ? bindings() : bindings; - return [...list] - .filter((binding) => !exclude.has(binding.name)) - .map((binding) => exportName(binding.name)); + return [...list].filter((binding) => !exclude.has(binding.name)); } catch { return undefined; } } +// The names code can import, for the description. A function of tools +// is called again, so the list is current whenever the backend +// describes itself. Any other iterable may be single-use, so it is +// left for the connection to read, and a function that throws leaves +// the list out rather than the whole description. +function exportNames(bindings: ToolBindings, exclude: ReadonlySet): string[] | undefined { + return readBindings(bindings, exclude)?.map((binding) => exportName(binding.name)); +} + /** * Turn a tool name into an export name: separators become `_`, other * characters that cannot appear in an identifier are dropped, a leading diff --git a/packages/computer/src/runtime/types.ts b/packages/computer/src/runtime/types.ts index c1e7d76e..b0db2669 100644 --- a/packages/computer/src/runtime/types.ts +++ b/packages/computer/src/runtime/types.ts @@ -66,8 +66,35 @@ export interface WorkspaceModuleFactory { * tool shows. Objects of functions are listed by their export names. */ readonly description?: string; + /** + * TypeScript declarations for the module's exports. Code reads them + * with `docs` and `types` from `cf:computer`, and by printing an + * export. An export with no declaration is shown as taking and + * returning `unknown`. + */ + readonly types?: WorkspaceModuleTypes; + /** Short snippets of code that call the module, shown by `docs`. */ + readonly examples?: readonly string[]; } +/** + * TypeScript declarations for a host module, keyed by the name each + * one declares. + * + * An entry named after an export declares that export, usually as a + * `function` with JSDoc. Any other entry declares a type the exports + * use, such as an `interface`, and is shown alongside each export + * whose declaration names it. + * + * ```ts + * types: { + * Forecast: "interface Forecast { high: number; low: number }", + * forecast: "/** The forecast for a city. *\/\nfunction forecast(city: string): Promise;", + * } + * ``` + */ +export type WorkspaceModuleTypes = Readonly>; + /** * A module caller source can import. * diff --git a/packages/computer/src/tools/ai-sdk/bindings.test.ts b/packages/computer/src/tools/ai-sdk/bindings.test.ts index c75a2ba4..a5209c03 100644 --- a/packages/computer/src/tools/ai-sdk/bindings.test.ts +++ b/packages/computer/src/tools/ai-sdk/bindings.test.ts @@ -166,4 +166,23 @@ describe("forAISDKTools", () => { tools = {}; await expect(binding.call({}, callContext())).rejects.toThrow('No tool named "gone"'); }); + it("gives each binding the tool's description and input schema, so ws:tools can declare it", () => { + const binding = bindingFor( + forAISDKTools({ + weather: tool({ + description: "The weather in a city.", + inputSchema: z.object({ city: z.string().describe("The city.") }), + execute: async () => "sunny", + }), + }), + "weather", + ); + + expect(binding.description).toBe("The weather in a city."); + expect(binding.inputSchema).toMatchObject({ + type: "object", + properties: { city: { type: "string", description: "The city." } }, + required: ["city"], + }); + }); }); diff --git a/packages/computer/src/tools/ai-sdk/bindings.ts b/packages/computer/src/tools/ai-sdk/bindings.ts index a4d71bcc..76788199 100644 --- a/packages/computer/src/tools/ai-sdk/bindings.ts +++ b/packages/computer/src/tools/ai-sdk/bindings.ts @@ -38,8 +38,11 @@ export function forAISDKTools(tools: ToolSet | (() => ToolSet)): () => ToolBindi return () => Object.entries(current()) .filter(([, tool]) => runsFromCode(tool)) - .map(([name]) => ({ + .map(([name, tool]) => ({ name, + // A description that is a function needs the turn's context, which code does not have. + ...(typeof tool.description === "string" ? { description: tool.description } : {}), + ...declaredSchema(tool), async call(input, context) { const tool = current()[name]; if (!tool?.execute || !runsFromCode(tool)) { @@ -65,6 +68,19 @@ export function forAISDKTools(tools: ToolSet | (() => ToolSet)): () => ToolBindi })); } +// The tool's input schema as JSON Schema, for its declaration. A schema +// that resolves only asynchronously, or fails to convert, leaves the +// declaration taking any object rather than failing the connection. +function declaredSchema(tool: Tool): { inputSchema?: object } { + try { + const schema: unknown = asSchema(tool.inputSchema).jsonSchema; + if (schema === null || typeof schema !== "object" || "then" in schema) return {}; + return { inputSchema: schema }; + } catch { + return {}; + } +} + // A schema with no validator, such as `jsonSchema()` without one, lets // the input through unchanged, as the AI SDK itself does. async function parseInput(name: string, tool: Tool, input: unknown): Promise { diff --git a/packages/computer/src/tools/ai-sdk/index.test.ts b/packages/computer/src/tools/ai-sdk/index.test.ts index 99e76666..ff797b14 100644 --- a/packages/computer/src/tools/ai-sdk/index.test.ts +++ b/packages/computer/src/tools/ai-sdk/index.test.ts @@ -1830,7 +1830,7 @@ describe("createAITools exec against a real JavaScript backend", () => { expect(description).toContain("`node:fs/promises`"); expect(description).toContain("- `tar-stream`: a bundled library."); expect(description).toContain("- `ws:weather`: exports `forecast`."); - expect(description).toContain("- `ws:git`: The workspace's Git repository tools"); + expect(description).toContain("- `ws:git`: The workspace's Git repository: "); expect(description).toContain("no direct network access"); expect(inputProperties(tools.exec)).toEqual(["command", "cwd", "env", "input"]); }); diff --git a/packages/computer/src/tools/pi-ai/bindings.test.ts b/packages/computer/src/tools/pi-ai/bindings.test.ts index 6b09ec6e..e364e7fd 100644 --- a/packages/computer/src/tools/pi-ai/bindings.test.ts +++ b/packages/computer/src/tools/pi-ai/bindings.test.ts @@ -230,4 +230,14 @@ describe("forPiTools", () => { // createPiTools reports a bad call as an error result rather than throwing. await expect(functions.read?.([{}], callContext())).resolves.toMatchObject({ isError: true }); }); + + it("gives each binding the tool's description and parameters, so ws:tools can declare it", () => { + const { tool } = agentTool("read", { content: [] }); + const fromTools = bindingFor(forPiTools([tool]), "read"); + expect(fromTools).toMatchObject({ description: "The read tool.", inputSchema: pathParameters }); + + const workspace = new Workspace({ storage: new SQLiteTestStorage() }); + const types = createToolBindings(forPiTools(createPiTools({ workspace }))).types; + expect(types?.read).toMatch(/^\/\*\*[\s\S]*\*\/\nfunction read\(input: \{/); + }); }); diff --git a/packages/computer/src/tools/pi-ai/bindings.ts b/packages/computer/src/tools/pi-ai/bindings.ts index 7cb163e9..69bb36d7 100644 --- a/packages/computer/src/tools/pi-ai/bindings.ts +++ b/packages/computer/src/tools/pi-ai/bindings.ts @@ -96,6 +96,8 @@ export function forPiTools( return () => declarations.map((declaration) => ({ name: declaration.name, + description: declaration.description, + inputSchema: declaration.parameters, readOnly: PI_READ_ONLY_TOOLS.has(declaration.name), async call(input, context) { const result = await execute( @@ -110,6 +112,8 @@ export function forPiTools( function agentBinding(tool: T, options: ForPiToolsOptions): ToolBinding { return { name: tool.name, + description: tool.description, + inputSchema: tool.parameters, readOnly: options.readOnly?.includes(tool.name) === true, async call(input, context) { const id = callId(); diff --git a/packages/computer/tests/script-runner-worker.ts b/packages/computer/tests/script-runner-worker.ts index b2c0a148..529a80b1 100644 --- a/packages/computer/tests/script-runner-worker.ts +++ b/packages/computer/tests/script-runner-worker.ts @@ -11,6 +11,7 @@ import { Workspace } from "../src/index.js"; import { createArtifactsModule } from "../src/modules/artifacts.js"; import { createContainerModule } from "../src/modules/container.js"; import { createGitModule } from "../src/modules/git.js"; +import { createToolBindings } from "../src/modules/tools.js"; export interface Env { HOST: DurableObjectNamespace; @@ -60,26 +61,43 @@ function fakeContainerBackend(): WorkspaceBackend { // The Worker Loader resolves imports differently under the legacy and // the new module registry, so the same backend runs on both. const NEW_REGISTRY_BACKEND = "worker-javascript-new-registry"; +// The other backends cut output at 64 bytes, too little to read what +// console.log printed. +const WIDE_OUTPUT_BACKEND = "worker-javascript-wide-output"; function javascriptBackend( env: Env, overrides: Partial< - Pick + Pick > = {}, ) { return new WorkerJavaScriptBackend({ loader: env.LOADER, - ...overrides, maxStdioBytes: 64, + ...overrides, maxCapabilityBytes: 1024, maxConcurrentCapabilityCalls: 2, modules: { "math-kit": "export const double = (value) => value * 2;", counter: "let count = 0; export const bump = () => ++count;", facade: `import { double } from "math-kit"; import { echo } from "ws:test-host"; export const both = async (value) => ({ doubled: double(value), echoed: await echo(value) });`, + "docs-kit": `import { docs } from "cf:computer"; export const gitDocs = () => docs("ws:git");`, "ws:git": createGitModule(), "ws:artifacts": createArtifactsModule(), "ws:container": createContainerModule(), + "ws:tools": createToolBindings([ + { + name: "get-weather", + description: "The weather in a city.", + readOnly: true, + inputSchema: { + type: "object", + properties: { city: { type: "string", description: "The city's name." } }, + required: ["city"], + }, + call: (input) => ({ text: `sunny in ${String(input.city)}`, isError: false }), + }, + ]), "ws:test-host": { async echo(args) { return { args: [...args] }; @@ -135,6 +153,7 @@ export class HostDO extends DurableObject { env.LOADER.load({ ...code, allowExperimental: true }), }, }), + javascriptBackend(env, { id: WIDE_OUTPUT_BACKEND, maxStdioBytes: 64 * 1024 }), fakeContainerBackend(), ], }); diff --git a/packages/computer/tests/script-runner.test.ts b/packages/computer/tests/script-runner.test.ts index 7796e133..69e93259 100644 --- a/packages/computer/tests/script-runner.test.ts +++ b/packages/computer/tests/script-runner.test.ts @@ -968,3 +968,272 @@ describe.each([ }); }); }); + +// cf:computer and printed values. A host module's code runs in the +// Durable Object, so these check what the isolate can learn about it +// without that code: its declarations, looked up or printed. +describe.each([ + ["the legacy module registry", "worker-javascript"], + ["the new module registry", "worker-javascript-new-registry"], +])("module introspection on %s", (_label, backend) => { + async function run(source: string) { + const response = await runtime({ source, cwd: "/workspace", backend }); + const text = await response.text(); + expect(response.status, text).toBe(200); + return (JSON.parse(text) as { result: { status: string; value?: unknown; stderr?: string } }) + .result; + } + + it("describes a host module from its namespace, its specifier, or one export", async () => { + const result = await run(` + import * as git from "ws:git"; + import { docs, types } from "cf:computer"; + export default () => ({ + byNamespace: docs(git), + bySpecifier: docs("ws:git"), + moduleTypes: types(git), + cli: types(git.cli), + printed: String(git.cli), + name: git.cli.name, + }); + `); + expect(result.status, result.stderr).toBe("completed"); + const value = result.value as Record; + expect(value.byNamespace).toBe(value.bySpecifier); + expect(value.byNamespace).toContain("# ws:git"); + expect(value.byNamespace).toContain('declare module "ws:git" {'); + expect(value.byNamespace).toContain('await cli({ argv: ["log", "--oneline", "-5"] });'); + expect(value.moduleTypes).toMatch(/^declare module "ws:git" \{/); + expect(value.cli).toContain("interface GitCliResult"); + expect(value.cli).toContain("function cli(input: { argv: string[]"); + expect(value.cli).not.toContain("function status"); + expect(value.printed).toContain("Promise { [host code] }"); + expect(value.name).toBe("cli"); + }); + + it("lists every module, and describes built-in and bundled ones", async () => { + const result = await run(` + import { docs, types } from "cf:computer"; + export default () => ({ + overview: docs(), + fs: types("node:fs/promises"), + self: types("cf:computer"), + bundled: docs("math-kit"), + node: docs("path"), + }); + `); + expect(result.status, result.stderr).toBe("completed"); + const value = result.value as Record; + for (const specifier of ["node:fs/promises", "ws:git", "ws:tools", "math-kit", "cf:computer"]) { + expect(value.overview).toContain(`\`${specifier}\``); + } + expect(value.fs).toContain("function readFile(path: string): Promise;"); + expect(value.self).toContain("function docs(target?: ModuleTarget): string;"); + expect(value.bundled).toContain('Object.keys(await import("math-kit"))'); + expect(value.node).toContain("`node:path` is the Node.js built-in"); + }); + + it("declares ws:tools exports from each tool's input schema", async () => { + const result = await run(` + import { get_weather } from "ws:tools"; + import { types } from "cf:computer"; + export default async () => ({ declared: types(get_weather), called: (await get_weather({ city: "Lisbon" })).text }); + `); + expect(result.status, result.stderr).toBe("completed"); + expect(result.value).toMatchObject({ called: "sunny in Lisbon" }); + const declared = (result.value as { declared: string }).declared; + expect(declared).toContain("/** The weather in a city. */"); + expect(declared).toContain("function get_weather(input: {"); + expect(declared).toContain("/** The city's name. */"); + expect(declared).toContain("city: string;"); + expect(declared).toContain("interface ToolResult"); + }); + + it("shows exports with no declarations as taking unknown", async () => { + const result = await run(` + import { types } from "cf:computer"; + export default () => types("ws:test-host"); + `); + expect(result).toMatchObject({ + status: "completed", + value: expect.stringContaining("function echo(...args: unknown[]): Promise;"), + }); + }); + + it("lets a configured module import cf:computer", async () => { + const result = await run(` + import { gitDocs } from "docs-kit"; + export default () => gitDocs(); + `); + expect(result).toMatchObject({ + status: "completed", + value: expect.stringContaining('declare module "ws:git"'), + }); + }); + + // This fixture cuts standard error short, so these read the message + // inside the isolate. + it("names the modules there are when asked about one that isn't", async () => { + const result = await run(` + import { docs } from "cf:computer"; + export default () => { try { docs("ws:nope"); } catch (error) { return error.message; } }; + `); + expect(result).toMatchObject({ + status: "completed", + value: expect.stringMatching(/no such module\. Modules code can import: .*ws:git/), + }); + }); + + it("refuses a function no host module exports", async () => { + const result = await run(` + import { docs } from "cf:computer"; + export default () => { try { docs(() => 1); } catch (error) { return error.message; } }; + `); + expect(result).toMatchObject({ + status: "completed", + value: expect.stringContaining("was passed a function no host module exports"), + }); + }); + + it("refuses another cf: module before running", async () => { + const response = await runtime({ + source: `import { x } from "cf:other"; export default () => x;`, + cwd: "/workspace", + backend, + }); + expect(response.status).toBe(400); + expect(await response.text()).toContain('The only one is \\"cf:computer\\"'); + }); +}); + +describe("console output", () => { + async function output(source: string) { + const response = await runtime({ + source, + cwd: "/workspace", + backend: "worker-javascript-wide-output", + }); + const text = await response.text(); + expect(response.status, text).toBe(200); + return (JSON.parse(text) as { result: { status: string; stdout: string; stderr: string } }) + .result; + } + + it("formats values the way Node.js does", async () => { + const result = await output(` + export default () => { + const loop = { name: "loop" }; + loop.self = loop; + console.log("text", 1, null, undefined, { a: 1, b: "x", list: [1, "two"] }); + console.log(loop); + console.log(new Map([["k", 1]]), new Set([1]), new Uint8Array([1, 2])); + console.log(function named() {}, class Thing {}); + console.log(Object.assign(new Error("boom"), { code: "EBOOM" }).code); + }; + `); + expect(result.status, result.stderr).toBe("completed"); + expect(result.stdout.split("\n")).toEqual([ + "text 1 null undefined { a: 1, b: 'x', list: [ 1, 'two' ] }", + "{ name: 'loop', self: [Circular] }", + "Map(1) { 'k' => 1 } Set(1) { 1 } Uint8Array(2) [ 1, 2 ]", + "[Function: named] [class Thing]", + "EBOOM", + "", + ]); + }); + + it("runs a module with no exports as the body of the default export", async () => { + const result = await output(` + // A comment before the imports. + import { writeFile, readFile } from "node:fs/promises"; + import { + status, // what changed + } from "ws:git"; + await writeFile("/workspace/script.txt", "from a script"); + const text = await readFile("/workspace/script.txt", "utf8"); + if (typeof status !== "function") throw new Error("no status"); + throw new Error("ran: " + text); + `); + expect(result).toMatchObject({ status: "failed" }); + expect(result.stderr).toContain("ran: from a script"); + + const returned = await output(` + import { readFile } from "node:fs/promises"; + return readFile("/workspace/script.txt", "utf8"); + `); + expect(returned).toMatchObject({ status: "completed" }); + }); + + it("runs a module with no exports as a script, for what it prints", async () => { + const result = await output(` + import * as git from "ws:git"; + import { docs } from "cf:computer"; + console.log(git); + console.log(docs().split("\\n")[0]); + `); + expect(result.status, result.stderr).toBe("completed"); + expect(result.stdout).toContain('[Module: ws:git]\ndeclare module "ws:git" {'); + expect(result.stdout).toContain("Modules code can import."); + }); + + it("points a call to an export a host module lacks at its docs", async () => { + const result = await output(` + import * as git from "ws:git"; + export default () => git.init({ dir: "repo" }); + `); + expect(result).toMatchObject({ + status: "failed", + stderr: expect.stringContaining( + 'is not a function. If that was a call into ws:git, docs("ws:git") from cf:computer lists what it exports.', + ), + }); + }); + + it("prints a host module's namespace and exports as their declarations", async () => { + const result = await output(` + import * as git from "ws:git"; + import { cli } from "ws:git"; + export default () => { + console.log(git); + console.log(cli); + console.log({ cli }); + }; + `); + expect(result.status, result.stderr).toBe("completed"); + expect(result.stdout).toContain('[Module: ws:git]\ndeclare module "ws:git" {'); + expect(result.stdout).toContain("Promise { [host code] }"); + expect(result.stdout).toContain("{ cli: [Function: cli] (ws:git) }"); + }); +}); + +describe("ws:git arguments", () => { + async function run(source: string) { + const response = await runtime({ source, cwd: "/workspace" }); + const text = await response.text(); + expect(response.status, text).toBe(200); + return (JSON.parse(text) as { result: { status: string; value?: unknown; stderr?: string } }) + .result; + } + + it.each([ + ['cli("status")', 'got ("status")'], + ['cli(["status"])', 'got (["status"])'], + ["cli({})", "argv must be an array of strings"], + ['cli({ argv: ["git", "status"] })', 'argv leaves out "git": pass ["status"]'], + ['status("/workspace")', "status takes one options object"], + [ + 'log({ cwd: "/workspace", maxCount: 3 })', + 'has no option "cwd" or "maxCount". Its options are dir, ref, depth.', + ], + ])("refuses %s with the call's shape", async (call, message) => { + const result = await run(` + import { cli, log, status } from "ws:git"; + export default async () => { + try { await ${call}; } catch (error) { return error.message; } + return "no error"; + }; + `); + expect(result).toMatchObject({ status: "completed", value: expect.stringContaining(message) }); + expect(result.value).toMatch(/Call it as|leaves out "git"|Its options are/); + }); +});