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`
#### Execute a virtual filesystem applet — `POST /vfs/{*path}`
- 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}`
- Params: `path: str, body: { content: str, mime?: 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
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-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 `;`
(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`
@@ -59,6 +60,7 @@ Applet executed successfully
```json
{
"ok": true,
"description": "…",
"result": "id;name\n1;Kaffee",
"columns": [
{

View File

@@ -41,7 +41,7 @@ function appletExecErrorStatus(error) {
if (message.includes('must be an object')) return 400;
if (message.includes('Unable to read applet') || message.includes('ENOENT')) return 404;
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 worker did not report a result payload')) return 500;
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
* 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-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 `;`
* (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`
@@ -127,6 +128,9 @@ export function register(app, basePath = '') {
* properties:
* ok:
* type: boolean
* description:
* type: string
* description: Applet description declared in the @applet-description header
* result:
* type: string
* 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-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.
//

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-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.
//

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-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.
//

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-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.
//

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-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.
//

View File

@@ -32,6 +32,9 @@ Applets can be executed by:
- `boolean`: boolean value (`true`, `false`, `1`, `0`, `"true"`, `"false"`).
- `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

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) {
if (typeof appletvfsfile !== 'string' || !appletvfsfile.trim()) {
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}`);
}
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 outputHeader = lines[1]?.match(/^\/\/ @applet-output-schema (.+)$/);
if (!inputHeader || !outputHeader) {
const descHeader = lines[2]?.match(/^\/\/ @applet-description (.+)$/);
if (!inputHeader || !outputHeader || !descHeader) {
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-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 description = parseAppletDescription(descHeader[1]);
return { source, outputColumns };
return { source, outputColumns, description, inputSchema };
}
function formatSandboxError(code, report) {
@@ -401,6 +430,7 @@ export function createJavascriptExecuteTool({ googleAuthId }) {
result.result = table.csv;
result.rows = table.rows;
result.columns = table.columns;
result.description = applet.description;
if (table.warnings.length) result.warnings = table.warnings;
}
if (truncated) {

View File

@@ -48,7 +48,7 @@ export const TOOL_DESCRIPTIONS = {
javascript_execute:
'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.',
find_files: