blob: ec8ef96e14d65d6d2419a0424674b3d73520cb9a [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// Schemas and built-in contracts come from the actual Rust application ABI.
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { compile } from 'json-schema-to-typescript';
const mode = process.argv[2];
if (!['generate', 'check'].includes(mode) || process.argv.length !== 3)
throw new Error('Usage: generate.mjs <generate|check>');
const root = fileURLToPath(new URL('../../../', import.meta.url));
const catalog = JSON.parse(
execFileSync('cargo', ['run', '--locked', '--quiet', '-p', 'maka-schema'], {
cwd: root,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'inherit'],
maxBuffer: 4 * 1024 * 1024,
}),
);
const header = readFileSync(new URL('../src/index.ts', import.meta.url), 'utf8').split(
'\n\n',
1,
)[0];
const banner =
header +
'\n\n// Generated from Rust runtime schemas. Run npm run schema:generate -w @maka-agent/plugin-sdk.\n';
// A $ref with annotation siblings makes the dereferencer clone its definition.
// Put annotations on an equivalent allOf wrapper so named Rust DTOs retain one
// TypeScript declaration, including their per-property documentation. Literal
// JSON data is never traversed as a schema.
function declarationSchema(value) {
if (Array.isArray(value)) return value.map(declarationSchema);
if (value === null || typeof value !== 'object') return value;
const result = Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
['default', 'const', 'enum', 'examples'].includes(key) ? child : declarationSchema(child),
]),
);
if (
typeof result.$ref === 'string' &&
Object.keys(result).length > 1 &&
Object.keys(result).every((key) =>
['$ref', 'description', 'default', 'title', 'deprecated', '$comment', 'examples'].includes(
key,
),
)
) {
const { $ref, ...annotations } = result;
return { ...annotations, allOf: [{ $ref }] };
}
return result;
}
const outputs = new Map();
const typeOptions = {
bannerComment: banner.trimEnd(),
unknownAny: true,
strictIndexSignatures: true,
format: false,
};
const runtimeTypes = await compile(
declarationSchema(catalog.schema),
catalog.schema.title,
typeOptions,
);
outputs.set(new URL('../src/generated/runtime.ts', import.meta.url), runtimeTypes);
// Types for the services used by the first-party client. These describe the
// original contracts, not endpoint aliases, live availability or authorization.
const runtimeNames = new Set(
[...runtimeTypes.matchAll(/^export (?:type|interface|enum) (\w+)/gm)].map((match) => match[1]),
);
const ordered = (value) =>
Array.isArray(value)
? value.map(ordered)
: value !== null && typeof value === 'object'
? Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, ordered(value[key])]),
)
: value;
const shape = (value) => {
if (typeof value === 'boolean') return JSON.stringify(value);
const { $schema, title, ...schema } = value;
return JSON.stringify(ordered(schema));
};
const methodDefinitions = {};
function methodSchema(document, prefix) {
if (typeof document === 'boolean') return document;
const { $defs = {}, $schema, title, ...schema } = document;
const canonical = (name, value) =>
runtimeNames.has(name) &&
catalog.schema.$defs[name] !== undefined &&
shape(value) === shape(catalog.schema.$defs[name]);
// A matching root alone is insufficient: its references must mean the same
// types in both documents, including recursive definitions.
const shared = Object.entries($defs).every(([name, value]) => canonical(name, value));
if (shared && canonical(title, schema)) return { tsType: `Runtime.${title}` };
const relocate = (value) => {
if (Array.isArray(value)) return value.map(relocate);
if (value === null || typeof value !== 'object') return value;
if (
shared &&
Object.keys(value).length === 1 &&
typeof value.$ref === 'string' &&
value.$ref.startsWith('#/$defs/')
) {
const name = value.$ref.slice('#/$defs/'.length);
if (Object.hasOwn($defs, name)) return { tsType: `Runtime.${name}` };
}
return Object.fromEntries(
Object.entries(value).map(([key, child]) => [
key,
key === '$ref' && typeof child === 'string'
? child.replace(/^#\/\$defs\//, `#/$defs/${prefix}`)
: ['default', 'const', 'enum', 'examples'].includes(key)
? child
: relocate(child),
]),
);
};
for (const [name, value] of Object.entries($defs)) {
methodDefinitions[`${prefix}${name}`] = shared
? { tsType: `Runtime.${name}` }
: relocate(value);
}
return relocate(schema);
}
const object = (properties) => ({
type: 'object',
properties,
required: Object.keys(properties),
additionalProperties: false,
});
const services = Object.fromEntries(
[
'agent',
'settings',
'settingsAuthentication',
'settingsSetup',
'deploymentManager',
'terminal',
].map((service) => [
service,
object(
Object.fromEntries(
Object.entries(catalog.contracts[service].interface.methods).map(([name, method]) => [
name,
object({
mode: { const: method.mode },
input: methodSchema(method.input, `${service}_${name}_input_`),
output: methodSchema(method.output, `${service}_${name}_output_`),
}),
]),
),
),
]),
);
outputs.set(
new URL('../src/generated/methods.ts', import.meta.url),
banner +
"import type * as Runtime from './runtime.js';\n\n" +
(await compile(
declarationSchema({ ...object(services), $defs: methodDefinitions }),
'ServiceMethods',
{ ...typeOptions, bannerComment: '' },
)),
);
outputs.set(
new URL('../src/contracts.ts', import.meta.url),
banner +
"import type { Contract } from './generated/runtime.js';\n\n" +
'export const contracts = ' +
JSON.stringify(catalog.contracts, null, 2) +
' satisfies Record<string, Contract>;\n',
);
outputs.set(
new URL('../src/configurations.ts', import.meta.url),
banner +
'export const configurations = ' +
JSON.stringify(catalog.configurations, null, 2) +
' as const;\n',
);
function reference(value, values, prefix) {
const encoded = JSON.stringify(value);
const match = Object.entries(values).find(
([, candidate]) => JSON.stringify(candidate) === encoded,
);
return match ? `${prefix}.${match[0]}` : encoded;
}
function referenceType(value, values, prefix) {
const source = reference(value, values, prefix);
return source.startsWith(`${prefix}.`) ? `typeof ${source}` : source;
}
const definitions = Object.entries(catalog.native).map(
([name, definition]) =>
`${JSON.stringify(name)}: { implementation: ${JSON.stringify(definition.implementation)}, ` +
`configuration: ${reference(definition.configuration, catalog.configurations, 'configurations')}, ` +
`exports: { ${Object.entries(definition.exports)
.map(
([name, contract]) =>
`${JSON.stringify(name)}: ${reference(contract, catalog.contracts, 'contracts')}`,
)
.join(', ')} } }`,
);
const definitionTypes = Object.entries(catalog.native).map(
([name, definition]) =>
`${JSON.stringify(name)}: { implementation: string; ` +
`configuration: ${referenceType(definition.configuration, catalog.configurations, 'configurations')}; ` +
`exports: { ${Object.entries(definition.exports)
.map(
([name, contract]) =>
`${JSON.stringify(name)}: ${referenceType(contract, catalog.contracts, 'contracts')}`,
)
.join('; ')} } }`,
);
outputs.set(
new URL('../src/native.ts', import.meta.url),
banner +
"import { contracts } from './contracts.js';\nimport { configurations } from './configurations.js';\n\n" +
'export const nativeImplementations = /* @__PURE__ */ ((): {\n' +
definitionTypes.join(';\n') +
'\n} => ({\n' +
definitions.join(',\n') +
'\n}))();\n',
);
for (const [url, source] of outputs) {
const content = execFileSync(
process.execPath,
[
fileURLToPath(new URL('../../../node_modules/@biomejs/biome/bin/biome', import.meta.url)),
'format',
'--stdin-file-path',
fileURLToPath(url),
],
{ cwd: root, input: source, encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 },
);
if (mode === 'generate') {
mkdirSync(new URL('./', url), { recursive: true });
writeFileSync(url, content);
} else if (readFileSync(url, 'utf8') !== content) {
throw new Error(`Runtime declarations are stale: ${fileURLToPath(url)}`);
}
}
console.log(
`${outputs.size} runtime declaration files ${mode === 'check' ? 'verified' : 'generated'}`,
);