This commit is contained in:
seb
2026-10-12 14:18:54 +02:00
parent 88ecf5bf5c
commit 06dc4629da
11 changed files with 56 additions and 12 deletions

View File

@@ -801,7 +801,7 @@ _none_
- Returns: `200 str` - Returns: `200 str`
#### Execute a virtual filesystem applet — `POST /vfs/{*path}` #### Execute a virtual filesystem applet — `POST /vfs/{*path}`
- Params: `path: str, body?: { input?: {}, timeout?: int, max_output?: int }` - Params: `path: str, body?: { input?: {}, timeout?: int, max_output?: int }`
- Returns: `200 { ok: bool, result: str, columns: { name: str, type: str }[], rows: int, stdout: str, consoleLines: int, durationMs: int, warnings?: str[], truncated?: bool, truncationNote?: str }` - Returns: `200 { ok: bool, result: str, columns: { name: str, type: str }[], rows: int, stdout: str, consoleLines: int, durationMs: int, description?: str, warnings?: str[], truncated?: bool, truncationNote?: str }`
#### Create or overwrite a virtual filesystem file — `PUT /vfs/{*path}` #### Create or overwrite a virtual filesystem file — `PUT /vfs/{*path}`
- Params: `path: str, body: { content: str, mime?: str }` - Params: `path: str, body: { content: str, mime?: str }`
- Returns: `200 { success: bool, file: { name: str, size: int, mime: str, updated_at: str } }` - Returns: `200 { success: bool, file: { name: str, size: int, mime: str, updated_at: str } }`

View File

@@ -7,9 +7,10 @@ Execute a virtual filesystem applet
Runs a `.applet.js` file from the virtual filesystem for the authenticated user's selected company Runs a `.applet.js` file from the virtual filesystem for the authenticated user's selected company
and returns the exported function's result. and returns the exported function's result.
The applet file must begin with two metadata lines: The applet file must begin with three metadata lines:
`// @applet-input-schema {JSON Schema}` followed by `// @applet-input-schema {JSON Schema}` followed by
`// @applet-output-schema {name:type;name:type}`. The input schema describes the GUI form posted as `// @applet-output-schema {name:type;name:type}` and
`// @applet-description "description"`. The input schema describes the GUI form posted as
`input`. The output line declares the RESULT COLUMNS as typed CSV — `name:type` pairs separated by `;` `input`. The output line declares the RESULT COLUMNS as typed CSV — `name:type` pairs separated by `;`
(e.g. `id:integer;name:string;active:boolean`); allowed types are `string`, `integer`, `number`, (e.g. `id:integer;name:string;active:boolean`); allowed types are `string`, `integer`, `number`,
`boolean` and `date`. The applet exports a function that receives the posted `input` as `applet_input` `boolean` and `date`. The applet exports a function that receives the posted `input` as `applet_input`
@@ -59,6 +60,7 @@ Applet executed successfully
```json ```json
{ {
"ok": true, "ok": true,
"description": "…",
"result": "id;name\n1;Kaffee", "result": "id;name\n1;Kaffee",
"columns": [ "columns": [
{ {

View File

@@ -41,7 +41,7 @@ function appletExecErrorStatus(error) {
if (message.includes('must be an object')) return 400; if (message.includes('must be an object')) return 400;
if (message.includes('Unable to read applet') || message.includes('ENOENT')) return 404; if (message.includes('Unable to read applet') || message.includes('ENOENT')) return 404;
if (message.includes('is not a file')) return 400; if (message.includes('is not a file')) return 400;
if (message.includes('Applet metadata') || message.includes('Applet input schema') || message.includes('Applet output schema')) return 400; if (message.includes('Applet metadata') || message.includes('Applet input schema') || message.includes('Applet output schema') || message.includes('Applet description')) return 400;
if (message.includes('Sandbox exceeded')) return 504; if (message.includes('Sandbox exceeded')) return 504;
if (message.includes('Sandbox worker did not report a result payload')) return 500; if (message.includes('Sandbox worker did not report a result payload')) return 500;
return 400; return 400;
@@ -71,9 +71,10 @@ export function register(app, basePath = '') {
* Runs a `.applet.js` file from the virtual filesystem for the authenticated user's selected company * Runs a `.applet.js` file from the virtual filesystem for the authenticated user's selected company
* and returns the exported function's result. * and returns the exported function's result.
* *
* The applet file must begin with two metadata lines: * The applet file must begin with three metadata lines:
* `// @applet-input-schema {JSON Schema}` followed by * `// @applet-input-schema {JSON Schema}` followed by
* `// @applet-output-schema {name:type;name:type}`. The input schema describes the GUI form posted as * `// @applet-output-schema {name:type;name:type}` and
* `// @applet-description "description"`. The input schema describes the GUI form posted as
* `input`. The output line declares the RESULT COLUMNS as typed CSV — `name:type` pairs separated by `;` * `input`. The output line declares the RESULT COLUMNS as typed CSV — `name:type` pairs separated by `;`
* (e.g. `id:integer;name:string;active:boolean`); allowed types are `string`, `integer`, `number`, * (e.g. `id:integer;name:string;active:boolean`); allowed types are `string`, `integer`, `number`,
* `boolean` and `date`. The applet exports a function that receives the posted `input` as `applet_input` * `boolean` and `date`. The applet exports a function that receives the posted `input` as `applet_input`
@@ -127,6 +128,9 @@ export function register(app, basePath = '') {
* properties: * properties:
* ok: * ok:
* type: boolean * type: boolean
* description:
* type: string
* description: Applet description declared in the @applet-description header
* result: * result:
* type: string * type: string
* description: Typed CSV table (`;`-separated, header row first) built from the returned rows and the declared output columns * description: Typed CSV table (`;`-separated, header row first) built from the returned rows and the declared output columns

View File

@@ -1,5 +1,6 @@
// @applet-input-schema {"type":"object","properties":{"query":{"type":"string","title":"Article name contains","default":"","description":"Case-insensitive substring match on the article name. Leave empty to include every article."},"limit":{"type":"integer","title":"Max rows","default":200,"minimum":1,"maximum":1000},"onlyTracked":{"type":"boolean","title":"Only stock-tracked articles","default":false,"description":"When true, articles with track_stock=false are omitted."}}} // @applet-input-schema {"type":"object","properties":{"query":{"type":"string","title":"Article name contains","default":"","description":"Case-insensitive substring match on the article name. Leave empty to include every article."},"limit":{"type":"integer","title":"Max rows","default":200,"minimum":1,"maximum":1000},"onlyTracked":{"type":"boolean","title":"Only stock-tracked articles","default":false,"description":"When true, articles with track_stock=false are omitted."}}}
// @applet-output-schema sku:string;name:string;net:number;taxRate:number;track_stock:boolean;categoryId:integer // @applet-output-schema sku:string;name:string;net:number;taxRate:number;track_stock:boolean;categoryId:integer
// @applet-description "Article catalogue — articles with their net price, resolved by walking the category tree."
// //
// Article catalogue — articles with their net price, resolved by walking the category tree. // Article catalogue — articles with their net price, resolved by walking the category tree.
// //

View File

@@ -1,5 +1,6 @@
// @applet-input-schema {"type":"object","properties":{"sku":{"type":"string","title":"Article SKU","description":"Exact SKU to inspect (e.g. \"42000009\")."}},"required":["sku"]} // @applet-input-schema {"type":"object","properties":{"sku":{"type":"string","title":"Article SKU","description":"Exact SKU to inspect (e.g. \"42000009\")."}},"required":["sku"]}
// @applet-output-schema key:string;value:string // @applet-output-schema key:string;value:string
// @applet-description "Article lookup — the raw GET /article/{sku} payload as a key/value sheet."
// //
// Article lookup — the raw `GET /article/{sku}` payload as a key/value sheet. // Article lookup — the raw `GET /article/{sku}` payload as a key/value sheet.
// //

View File

@@ -1,5 +1,6 @@
// @applet-input-schema {"type":"object","properties":{"query":{"type":"string","title":"Category name contains","default":"","description":"Case-insensitive substring match on the category name. Leave empty to include every category."},"limit":{"type":"integer","title":"Max rows","default":100,"minimum":1,"maximum":500},"sortBy":{"type":"string","title":"Sort by","default":"articles","enum":["articles","name","depth"],"description":"articles = most articles first, name = A–Z, depth = tree depth then name."}}} // @applet-input-schema {"type":"object","properties":{"query":{"type":"string","title":"Category name contains","default":"","description":"Case-insensitive substring match on the category name. Leave empty to include every category."},"limit":{"type":"integer","title":"Max rows","default":100,"minimum":1,"maximum":500},"sortBy":{"type":"string","title":"Sort by","default":"articles","enum":["articles","name","depth"],"description":"articles = most articles first, name = A–Z, depth = tree depth then name."}}}
// @applet-output-schema id:integer;name:string;depth:integer;articles:integer;children:integer // @applet-output-schema id:integer;name:string;depth:integer;articles:integer;children:integer
// @applet-description "Category inventory — every category with its article count and child count."
// //
// Category inventory — every category with its article count and child count. // Category inventory — every category with its article count and child count.
// //

View File

@@ -1,5 +1,6 @@
// @applet-input-schema {"type":"object","properties":{"dateFrom":{"type":"string","title":"From date (YYYY-MM-DD)","default":"","description":"Only include documents dated on or after this day. Leave empty for no lower bound."},"dateTo":{"type":"string","title":"To date (YYYY-MM-DD)","default":"","description":"Only include documents dated on or before this day. Leave empty for no upper bound."},"limit":{"type":"integer","title":"Max rows","default":100,"minimum":1,"maximum":500}}} // @applet-input-schema {"type":"object","properties":{"dateFrom":{"type":"string","title":"From date (YYYY-MM-DD)","default":"","description":"Only include documents dated on or after this day. Leave empty for no lower bound."},"dateTo":{"type":"string","title":"To date (YYYY-MM-DD)","default":"","description":"Only include documents dated on or before this day. Leave empty for no upper bound."},"limit":{"type":"integer","title":"Max rows","default":100,"minimum":1,"maximum":500}}}
// @applet-output-schema number:string;date:string;supplier:string;lines:integer;net:number // @applet-output-schema number:string;date:string;supplier:string;lines:integer;net:number
// @applet-description "Goods receipt register — deliveries with their line-item count and net total."
// //
// Goods receipt register — deliveries with their line-item count and net total. // Goods receipt register — deliveries with their line-item count and net total.
// //

View File

@@ -1,5 +1,6 @@
// @applet-input-schema {"type":"object","properties":{"month":{"type":"string","title":"Month (YYYY-MM)","default":"","description":"Only include orders placed in this month. Leave empty for all months."},"limit":{"type":"integer","title":"Max rows","default":50,"minimum":1,"maximum":500}}} // @applet-input-schema {"type":"object","properties":{"month":{"type":"string","title":"Month (YYYY-MM)","default":"","description":"Only include orders placed in this month. Leave empty for all months."},"limit":{"type":"integer","title":"Max rows","default":50,"minimum":1,"maximum":500}}}
// @applet-output-schema month:string;orders:integer;positions:integer;net:number // @applet-output-schema month:string;orders:integer;positions:integer;net:number
// @applet-description "Order revenue by month — orders grouped per calendar month."
// //
// Order revenue by month — orders grouped per calendar month. // Order revenue by month — orders grouped per calendar month.
// //

View File

@@ -32,6 +32,9 @@ Applets can be executed by:
- `boolean`: boolean value (`true`, `false`, `1`, `0`, `"true"`, `"false"`). - `boolean`: boolean value (`true`, `false`, `1`, `0`, `"true"`, `"false"`).
- `date`: Date object or ISO string. - `date`: Date object or ISO string.
`@applet-description`
- Short human-readable summary of what the applet computes (e.g. `"Article catalogue with net prices"`).
--- ---
# Execution Environment & Globals # Execution Environment & Globals

View File

@@ -197,6 +197,32 @@ function runInWorker({ code, timeoutMs, vfsRoot, googleAuthId, appletInput, appl
}); });
} }
/**
* Parse the applet description header.
*
* Accepts either a quoted string (e.g. `"Lorem ipsum"` or `'Lorem ipsum'`) or bare text.
* Strips surrounding quotes if present and trims whitespace.
*
* @param {string} header raw text after the `// @applet-description ` marker
* @returns {string}
*/
function parseAppletDescription(header) {
const text = String(header || '').trim();
if (!text) {
throw new ToolError('Applet description cannot be empty.');
}
if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
try {
const parsed = JSON.parse(text);
if (typeof parsed === 'string' && parsed.trim()) return parsed.trim();
} catch {
const unquoted = text.slice(1, -1).trim();
if (unquoted) return unquoted;
}
}
return text;
}
function readApplet(vfsRoot, appletvfsfile) { function readApplet(vfsRoot, appletvfsfile) {
if (typeof appletvfsfile !== 'string' || !appletvfsfile.trim()) { if (typeof appletvfsfile !== 'string' || !appletvfsfile.trim()) {
throw new ToolError('"appletvfsfile" must be a non-empty VFS-relative file path.'); throw new ToolError('"appletvfsfile" must be a non-empty VFS-relative file path.');
@@ -228,14 +254,16 @@ function readApplet(vfsRoot, appletvfsfile) {
throw new ToolError(`Unable to read applet "${appletPath}" from the VFS: ${err.message}`); throw new ToolError(`Unable to read applet "${appletPath}" from the VFS: ${err.message}`);
} }
const lines = source.replace(/^\uFEFF/, '').split(/\r?\n/, 3); const lines = source.replace(/^\uFEFF/, '').split(/\r?\n/, 4);
const inputHeader = lines[0]?.match(/^\/\/ @applet-input-schema (.+)$/); const inputHeader = lines[0]?.match(/^\/\/ @applet-input-schema (.+)$/);
const outputHeader = lines[1]?.match(/^\/\/ @applet-output-schema (.+)$/); const outputHeader = lines[1]?.match(/^\/\/ @applet-output-schema (.+)$/);
if (!inputHeader || !outputHeader) { const descHeader = lines[2]?.match(/^\/\/ @applet-description (.+)$/);
if (!inputHeader || !outputHeader || !descHeader) {
throw new ToolError( throw new ToolError(
'Applet metadata must be on the first two lines: ' + 'Applet metadata must be on the first three lines: ' +
'`// @applet-input-schema {JSON Schema}` followed by ' + '`// @applet-input-schema {JSON Schema}` followed by ' +
'`// @applet-output-schema name:type;name:type` (typed CSV columns).' '`// @applet-output-schema name:type;name:type` (typed CSV columns) and ' +
'`// @applet-description "description"`.'
); );
} }
@@ -251,8 +279,9 @@ function readApplet(vfsRoot, appletvfsfile) {
} }
const outputColumns = parseTypedCsvColumns(outputHeader[1]); const outputColumns = parseTypedCsvColumns(outputHeader[1]);
const description = parseAppletDescription(descHeader[1]);
return { source, outputColumns }; return { source, outputColumns, description, inputSchema };
} }
function formatSandboxError(code, report) { function formatSandboxError(code, report) {
@@ -401,6 +430,7 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
result.result = table.csv; result.result = table.csv;
result.rows = table.rows; result.rows = table.rows;
result.columns = table.columns; result.columns = table.columns;
result.description = applet.description;
if (table.warnings.length) result.warnings = table.warnings; if (table.warnings.length) result.warnings = table.warnings;
} }
if (truncated) { if (truncated) {

View File

@@ -48,7 +48,7 @@ export const TOOL_DESCRIPTIONS = {
javascript_execute: javascript_execute:
'Execute JS in an isolated sandbox via inline `code` (assign to `module.exports`) or `appletvfsfile` ' + 'Execute JS in an isolated sandbox via inline `code` (assign to `module.exports`) or `appletvfsfile` ' +
'(.applet.js returning typed CSV per @applet-input-schema and @applet-output-schema; details in applet.SKILL). ' + '(.applet.js returning typed CSV per @applet-input-schema, @applet-output-schema, and @applet-description; details in applet.SKILL). ' +
'Pre-injected globals: fs/vfs, sharp, ExcelJS, PDFDocument, crypto, and fetch("/path"). No require/import.', 'Pre-injected globals: fs/vfs, sharp, ExcelJS, PDFDocument, crypto, and fetch("/path"). No require/import.',
find_files: find_files: