@formio/uag 2.0.0-rc.1 → 2.0.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +17 -15
  2. package/bin/uag-eval.js +7 -3
  3. package/bin/uag.js +48 -5
  4. package/lib/UAGFormInterface.d.ts +12 -0
  5. package/lib/UAGFormInterface.js +55 -12
  6. package/lib/UAGProjectInterface.d.ts +9 -1
  7. package/lib/UAGProjectInterface.js +25 -7
  8. package/lib/actionLogs.d.ts +1 -10
  9. package/lib/actionLogs.js +5 -3
  10. package/lib/actions/runTask.js +4 -3
  11. package/lib/agent/anonymous.js +5 -4
  12. package/lib/agent/chat.d.ts +3 -2
  13. package/lib/agent/chat.js +35 -99
  14. package/lib/agent/engines.d.ts +10 -6
  15. package/lib/agent/engines.js +19 -10
  16. package/lib/agent/errors.d.ts +25 -0
  17. package/lib/agent/errors.js +132 -0
  18. package/lib/agent/index.d.ts +1 -1
  19. package/lib/agent/index.js +2 -1
  20. package/lib/agent/remote.d.ts +5 -1
  21. package/lib/agent/remote.js +25 -2
  22. package/lib/agent/router.d.ts +32 -2
  23. package/lib/agent/router.js +37 -25
  24. package/lib/agent/stubs.d.ts +13 -2
  25. package/lib/agent/stubs.js +18 -5
  26. package/lib/agent/tools.d.ts +24 -3
  27. package/lib/agent/tools.js +47 -4
  28. package/lib/config.d.ts +31 -4
  29. package/lib/database.d.ts +3 -8
  30. package/lib/database.js +2 -19
  31. package/lib/documents/convert.d.ts +38 -0
  32. package/lib/documents/convert.js +151 -0
  33. package/lib/documents/converters.d.ts +28 -0
  34. package/lib/documents/converters.js +149 -0
  35. package/lib/documents/detect.d.ts +31 -0
  36. package/lib/documents/detect.js +169 -0
  37. package/lib/documents/index.d.ts +9 -0
  38. package/lib/documents/index.js +43 -0
  39. package/lib/documents/mime.d.ts +39 -0
  40. package/lib/documents/mime.js +100 -0
  41. package/lib/documents/plugins.d.ts +18 -0
  42. package/lib/documents/plugins.js +65 -0
  43. package/lib/documents/readers.d.ts +19 -0
  44. package/lib/documents/readers.js +64 -0
  45. package/lib/documents/routes.d.ts +52 -0
  46. package/lib/documents/routes.js +99 -0
  47. package/lib/documents/types.d.ts +126 -0
  48. package/lib/documents/types.js +2 -0
  49. package/lib/documents/uagJs.d.ts +17 -0
  50. package/lib/documents/uagJs.js +157 -0
  51. package/lib/formflow.d.ts +11 -4
  52. package/lib/formflow.js +5 -5
  53. package/lib/index.d.ts +65 -9
  54. package/lib/index.js +228 -26
  55. package/lib/markdown.d.ts +1 -0
  56. package/lib/markdown.js +166 -0
  57. package/lib/modules/api.d.ts +25 -0
  58. package/lib/modules/api.js +98 -0
  59. package/lib/modules/index.d.ts +4 -0
  60. package/lib/modules/index.js +24 -0
  61. package/lib/modules/routes.d.ts +14 -0
  62. package/lib/modules/routes.js +21 -0
  63. package/lib/modules/types.d.ts +68 -0
  64. package/lib/modules/types.js +5 -0
  65. package/lib/pluginFiles.d.ts +28 -0
  66. package/lib/pluginFiles.js +101 -0
  67. package/lib/plugins.d.ts +17 -0
  68. package/lib/plugins.js +93 -0
  69. package/lib/richText.d.ts +20 -0
  70. package/lib/richText.js +99 -0
  71. package/lib/router.d.ts +9 -0
  72. package/lib/router.js +19 -0
  73. package/lib/runner.d.ts +2 -0
  74. package/lib/runner.js +11 -3
  75. package/lib/runs.d.ts +17 -79
  76. package/lib/runs.js +10 -71
  77. package/lib/testing.d.ts +74 -0
  78. package/lib/testing.js +180 -0
  79. package/lib/tools/SchemaBuilder.d.ts +0 -5
  80. package/lib/tools/SchemaBuilder.js +0 -8
  81. package/lib/tools/agentDecide.js +2 -2
  82. package/lib/tools/agentProvide.d.ts +0 -5
  83. package/lib/tools/agentProvide.js +7 -40
  84. package/lib/tools/collectData.js +2 -1
  85. package/lib/tools/index.d.ts +3 -2
  86. package/lib/tools/index.js +5 -5
  87. package/lib/tools/readDocument.d.ts +17 -0
  88. package/lib/tools/readDocument.js +99 -0
  89. package/lib/tools/submissionUpdate.js +3 -2
  90. package/lib/tools/utils.d.ts +40 -0
  91. package/lib/tools/utils.js +16 -1
  92. package/lib/version.d.ts +2 -0
  93. package/lib/version.js +5 -0
  94. package/package.json +17 -10
  95. package/testing.d.ts +1 -0
  96. package/testing.js +2 -0
package/lib/plugins.js ADDED
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.toolPluginType = exports.enginePluginType = void 0;
4
+ exports.listEngines = listEngines;
5
+ exports.listTools = listTools;
6
+ const uag_sdk_1 = require("@formio/uag-sdk");
7
+ const tools_1 = require("./agent/tools");
8
+ const engines_1 = require("./agent/engines");
9
+ const version_1 = require("./version");
10
+ /**
11
+ * Engines and MCP tools, listed in the plugin registry (phase 1). The UAG keeps
12
+ * loading them its own way (UAG_ENGINES, config.tools); the registry only lists
13
+ * them, so they appear in GET {project}/plugins. Settings values are never listed.
14
+ */
15
+ exports.enginePluginType = {
16
+ type: 'engine',
17
+ title: 'Engine',
18
+ apiVersions: [1],
19
+ editForm: 'none',
20
+ // A misconfigured engine stops startup.
21
+ unavailable: 'error',
22
+ registration: 'internal',
23
+ stability: 'stable',
24
+ validate: (plugin) => uag_sdk_1.ENGINE_NAME.test(plugin.name) ? [] : [`engine name "${plugin.name}" must be lowercase letters, digits and "-", starting with a letter`],
25
+ manifest: (plugin) => ({
26
+ kinds: plugin.kinds,
27
+ accepts: plugin.accepts,
28
+ generate: plugin.generate,
29
+ instances: plugin.instances
30
+ })
31
+ };
32
+ exports.toolPluginType = {
33
+ type: 'tool',
34
+ title: 'MCP tool',
35
+ apiVersions: [1],
36
+ editForm: 'none',
37
+ unavailable: 'error',
38
+ registration: 'internal',
39
+ stability: 'stable',
40
+ validate: (plugin) => /^[A-Za-z][A-Za-z0-9_-]*$/.test(plugin.name) ? [] : [`tool name "${plugin.name}" must start with a letter and hold only letters, digits, "_" and "-"`],
41
+ manifest: (plugin) => ({ annotations: plugin.annotations ?? null })
42
+ };
43
+ const semver = (version) => (version && /^\d+\.\d+\.\d+/.test(version)) ? version : version_1.UAG_VERSION;
44
+ /** List each loaded engine type: its instances, kinds, what agent instances read, and whether they generate. */
45
+ function listEngines(registry, project) {
46
+ const engines = project.formflowEngines();
47
+ const entries = (0, engines_1.engineTypesOf)(engines).map((type) => {
48
+ const instances = type.instances.map((name) => engines.get(name)).filter(Boolean);
49
+ const agents = instances.filter((engine) => engine.kind === 'agent');
50
+ const accepts = [...new Set(agents.flatMap((engine) => Array.isArray(engine.accepts) ? engine.accepts : ['text']))];
51
+ return {
52
+ type: 'engine',
53
+ name: type.name,
54
+ title: type.title,
55
+ version: semver(type.version),
56
+ apiVersion: 1,
57
+ kinds: type.kinds,
58
+ accepts,
59
+ generate: agents.some((engine) => typeof engine.generate === 'function'),
60
+ instances: type.instances
61
+ };
62
+ });
63
+ registry.addType(exports.enginePluginType, '@formio/uag');
64
+ registry.add(entries, 'UAG_ENGINES', { server: true });
65
+ }
66
+ /**
67
+ * List every MCP tool the project serves (built-in, then config.tools). A name
68
+ * served twice is listed once, with a warning: listing never refuses what the
69
+ * tool loader allows.
70
+ */
71
+ async function listTools(registry, project, log = console) {
72
+ const seen = new Set();
73
+ const entries = [];
74
+ for (const tool of await (0, tools_1.allTools)(project)) {
75
+ const name = String(tool.name);
76
+ if (seen.has(name)) {
77
+ log.warn(`[plugins] tool:${name} is defined more than once; it is listed once`);
78
+ continue;
79
+ }
80
+ seen.add(name);
81
+ entries.push({
82
+ type: 'tool',
83
+ name,
84
+ title: tool.title || name,
85
+ description: tool.description,
86
+ version: version_1.UAG_VERSION,
87
+ apiVersion: 1,
88
+ annotations: tool.annotations
89
+ });
90
+ }
91
+ registry.addType(exports.toolPluginType, '@formio/uag');
92
+ registry.add(entries, 'config.tools', { server: true });
93
+ }
@@ -0,0 +1,20 @@
1
+ import type { Component } from '@formio/core';
2
+ /**
3
+ * HTML for rich text fields (textareas with the CKEditor or Quill editor).
4
+ *
5
+ * What an agent engine writes into such a field is reduced to the markup the
6
+ * editors keep, because the engine's input can be steered (a document it reads
7
+ * is someone else's text), and the field is later rendered for reviewers. The
8
+ * HTML is parsed and written out again: allowed elements with their allowed
9
+ * attributes, text escaped, everything else dropped. An element that is not
10
+ * allowed is dropped and its children kept, except for elements whose content
11
+ * is never text to show (scripts, styles, frames, embedded documents).
12
+ */
13
+ /** Allowed elements, each with the attributes it may keep. */
14
+ export declare const RICH_TEXT_TAGS: Record<string, string[]>;
15
+ /** Whether a component is a rich text field, whose value is HTML. */
16
+ export declare function isRichText(component: Component | undefined): boolean;
17
+ /** Reduce HTML to the markup rich text editors keep (RICH_TEXT_TAGS). */
18
+ export declare function sanitizeRichText(html: string): string;
19
+ /** The rule agents are given for a rich text field's value. */
20
+ export declare const RICH_TEXT_RULE: string;
@@ -0,0 +1,99 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RICH_TEXT_RULE = exports.RICH_TEXT_TAGS = void 0;
4
+ exports.isRichText = isRichText;
5
+ exports.sanitizeRichText = sanitizeRichText;
6
+ const node_html_parser_1 = require("node-html-parser");
7
+ /**
8
+ * HTML for rich text fields (textareas with the CKEditor or Quill editor).
9
+ *
10
+ * What an agent engine writes into such a field is reduced to the markup the
11
+ * editors keep, because the engine's input can be steered (a document it reads
12
+ * is someone else's text), and the field is later rendered for reviewers. The
13
+ * HTML is parsed and written out again: allowed elements with their allowed
14
+ * attributes, text escaped, everything else dropped. An element that is not
15
+ * allowed is dropped and its children kept, except for elements whose content
16
+ * is never text to show (scripts, styles, frames, embedded documents).
17
+ */
18
+ /** Allowed elements, each with the attributes it may keep. */
19
+ exports.RICH_TEXT_TAGS = {
20
+ p: [], br: [], hr: [],
21
+ strong: [], b: [], em: [], i: [], u: [], s: [], sub: [], sup: [], code: [], pre: [],
22
+ h1: [], h2: [], h3: [], h4: [], h5: [], h6: [],
23
+ ul: [], ol: [], li: [],
24
+ blockquote: [],
25
+ a: ['href'],
26
+ figure: [], table: [], thead: [], tbody: [], tfoot: [], tr: [],
27
+ th: ['colspan', 'rowspan'], td: ['colspan', 'rowspan']
28
+ };
29
+ /** Elements dropped with everything inside them. */
30
+ const DROPPED_WITH_CONTENT = new Set(['script', 'style', 'noscript', 'template', 'iframe', 'frame', 'frameset', 'object', 'embed', 'svg', 'math', 'head', 'title', 'textarea', 'select']);
31
+ const VOID = new Set(['br', 'hr']);
32
+ /** Whether a component is a rich text field, whose value is HTML. */
33
+ function isRichText(component) {
34
+ const editor = component?.editor;
35
+ return component?.type === 'textarea' && (editor === 'ckeditor' || editor === 'quill');
36
+ }
37
+ function escapeText(text) {
38
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
39
+ }
40
+ function escapeAttribute(value) {
41
+ return escapeText(value).replace(/"/g, '&quot;');
42
+ }
43
+ /** Only links to web pages and mail addresses: never `javascript:`, `data:` or the like. */
44
+ function safeHref(value) {
45
+ const href = String(value || '').replace(/[\u0000-\u001F\u007F\s]+/g, '');
46
+ return /^(https?:\/\/|mailto:)/i.test(href) ? String(value).trim() : null;
47
+ }
48
+ function attributesOf(element, allowed) {
49
+ let out = '';
50
+ for (const name of allowed) {
51
+ const raw = element.getAttribute(name);
52
+ if (raw === undefined || raw === null)
53
+ continue;
54
+ let value = raw;
55
+ if (name === 'href')
56
+ value = safeHref(raw);
57
+ if ((name === 'colspan' || name === 'rowspan'))
58
+ value = /^\d{1,3}$/.test(raw.trim()) ? raw.trim() : null;
59
+ if (value === null)
60
+ continue;
61
+ out += ` ${name}="${escapeAttribute(value)}"`;
62
+ }
63
+ return out;
64
+ }
65
+ function write(node) {
66
+ if (node.nodeType === node_html_parser_1.NodeType.TEXT_NODE) {
67
+ // `text` is the decoded text; it is escaped once, here.
68
+ return escapeText(node.text);
69
+ }
70
+ if (node.nodeType !== node_html_parser_1.NodeType.ELEMENT_NODE)
71
+ return '';
72
+ const element = node;
73
+ const children = () => element.childNodes.map(write).join('');
74
+ const tag = String(element.rawTagName || '').toLowerCase();
75
+ if (!tag)
76
+ return children();
77
+ if (DROPPED_WITH_CONTENT.has(tag))
78
+ return '';
79
+ const allowed = exports.RICH_TEXT_TAGS[tag];
80
+ if (!allowed)
81
+ return children();
82
+ const open = `<${tag}${attributesOf(element, allowed)}>`;
83
+ return VOID.has(tag) ? open : `${open}${children()}</${tag}>`;
84
+ }
85
+ /** Reduce HTML to the markup rich text editors keep (RICH_TEXT_TAGS). */
86
+ function sanitizeRichText(html) {
87
+ const root = (0, node_html_parser_1.parse)(String(html ?? ''), {
88
+ comment: false,
89
+ lowerCaseTagName: true,
90
+ // Read as raw text, so their content is never parsed as markup; they are dropped.
91
+ blockTextElements: { script: true, style: true, noscript: true }
92
+ });
93
+ return root.childNodes.map(write).join('').trim();
94
+ }
95
+ /** The rule agents are given for a rich text field's value. */
96
+ exports.RICH_TEXT_RULE = 'The value is HTML for a rich text editor. Use only these elements: ' +
97
+ Object.keys(exports.RICH_TEXT_TAGS).join(', ') +
98
+ ', with no attributes except href on a (http, https or mailto links only) and colspan and rowspan on td and th. ' +
99
+ 'No other elements, styles, classes, images or scripts, which are removed. Write HTML, not Markdown, and do not wrap it in a code fence.';
package/lib/router.d.ts CHANGED
@@ -1,3 +1,12 @@
1
1
  import { Router } from 'express';
2
2
  import { UAGProjectInterface } from './UAGProjectInterface';
3
+ import type { ScopedTokenClaims } from './formflow';
4
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
+ /**
6
+ * Hold a scoped token to its tools whatever built the server: a project
7
+ * interface a module replaces may override buildMcpServer() and register tools
8
+ * without the scope it was given. Every tool the token does not list, and every
9
+ * tool not annotated read-only, is disabled, so it is neither listed nor run.
10
+ */
11
+ export declare function restrictToScope(server: McpServer, scope: Pick<ScopedTokenClaims, 'tools'>): void;
3
12
  export declare function UAGRouter(project: UAGProjectInterface): Router;
package/lib/router.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.restrictToScope = restrictToScope;
3
4
  exports.UAGRouter = UAGRouter;
4
5
  const express_1 = require("express");
5
6
  const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
@@ -30,6 +31,22 @@ const handleResponseError = (error, res) => {
30
31
  res.status(500).json(createJsonRpcErrorResponse(-32603, "Internal Server Error"));
31
32
  return false;
32
33
  };
34
+ /**
35
+ * Hold a scoped token to its tools whatever built the server: a project
36
+ * interface a module replaces may override buildMcpServer() and register tools
37
+ * without the scope it was given. Every tool the token does not list, and every
38
+ * tool not annotated read-only, is disabled, so it is neither listed nor run.
39
+ */
40
+ function restrictToScope(server, scope) {
41
+ const allowed = new Set(scope.tools || []);
42
+ // The SDK keeps registered tools here; it has no public list of them.
43
+ const registered = server._registeredTools || {};
44
+ for (const [name, tool] of Object.entries(registered)) {
45
+ if (!allowed.has(name) || tool?.annotations?.readOnlyHint !== true) {
46
+ tool.disable?.();
47
+ }
48
+ }
49
+ }
33
50
  /**
34
51
  * Build a per-request McpServer + transport (stateless mode) and tear both down
35
52
  * when the response closes. A fresh instance per request keeps concurrent requests
@@ -37,6 +54,8 @@ const handleResponseError = (error, res) => {
37
54
  */
38
55
  const connectMcpTransport = async (project, res, scope) => {
39
56
  const server = await project.buildMcpServer(scope);
57
+ if (scope)
58
+ restrictToScope(server, scope);
40
59
  const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
41
60
  sessionIdGenerator: undefined, // Use "undefined" to trigger stateless mode.
42
61
  });
package/lib/runner.d.ts CHANGED
@@ -53,6 +53,8 @@ export type TaskRunOutcome = {
53
53
  }[];
54
54
  /** Whether the results were saved (nothing is saved when nothing changed). */
55
55
  saved: boolean;
56
+ /** Answers the form's validation refused, left out: path to the form's message. */
57
+ refused?: Record<string, string>;
56
58
  error?: string;
57
59
  };
58
60
  /**
package/lib/runner.js CHANGED
@@ -268,8 +268,9 @@ class TaskRunner {
268
268
  const service = (0, formflow_1.formflow)(this.project);
269
269
  const load = async () => {
270
270
  const stored = await form.loadSubmission(job.submission, await act());
271
+ // Form.io answers a submission the caller may not read as missing, so say both.
271
272
  if (!stored)
272
- throw new formflow_1.FormflowError(404, `Submission "${job.submission}" was not found.`);
273
+ throw new formflow_1.FormflowError(404, `Submission "${job.submission}" was not found, or the user the run acts as may not read it (give submitters Read Own access, or the action an agent role).`);
273
274
  return stored;
274
275
  };
275
276
  let stored = await load();
@@ -278,8 +279,14 @@ class TaskRunner {
278
279
  const engines = run.results.map((result) => ({ kind: result.kind, engine: result.engine ?? null, model: result.model ?? null }));
279
280
  const failed = run.failed.map((group) => group.error || 'no engine could answer').join('; ');
280
281
  const changed = Object.keys(run.written).length > 0 || run.cleared.length > 0;
282
+ const refused = run.refused || {};
283
+ if (Object.keys(refused).length) {
284
+ console.warn(`[formio-uag] Run task "${job.task}" left out answers the form refused for submission ${job.submission} of "${job.form}": ` +
285
+ Object.entries(refused).map(([path, message]) => `${path} (${message})`).join('; '));
286
+ }
287
+ const left = Object.keys(refused).length ? { refused } : {};
281
288
  if (!changed) {
282
- return { status: failed ? 'failed' : 'done', written: {}, cleared: [], engines, saved: false, ...(failed && { error: failed }) };
289
+ return { status: failed ? 'failed' : 'done', written: {}, cleared: [], engines, saved: false, ...left, ...(failed && { error: failed }) };
283
290
  }
284
291
  try {
285
292
  // Only the task's fields changed; `modified` refuses the save if a person saved in the meantime.
@@ -291,7 +298,7 @@ class TaskRunner {
291
298
  ...(job.caller ? { [exports.RUN_TASK_CALLER]: job.caller, [exports.RUN_TASK_IDENTITY]: job.identity } : { [exports.RUN_TASK_AS_USER]: true }),
292
299
  [exports.RUN_TASK_CHAIN]: (job.chain || 0) + 1
293
300
  });
294
- return { status: failed ? 'failed' : 'done', written: run.written, cleared: run.cleared, engines, saved: true, ...(failed && { error: failed }) };
301
+ return { status: failed ? 'failed' : 'done', written: run.written, cleared: run.cleared, engines, saved: true, ...left, ...(failed && { error: failed }) };
295
302
  }
296
303
  catch (err) {
297
304
  if (err?.name !== 'ConflictError' || attempt >= 2) {
@@ -313,6 +320,7 @@ function resultOf(outcome) {
313
320
  engines: outcome.engines,
314
321
  written: Object.keys(outcome.written),
315
322
  cleared: outcome.cleared,
323
+ ...(outcome.refused && { refused: Object.keys(outcome.refused) }),
316
324
  saved: outcome.saved,
317
325
  ...(outcome.error && { error: outcome.error })
318
326
  };
package/lib/runs.d.ts CHANGED
@@ -1,89 +1,27 @@
1
- import { Collection, ObjectId } from 'mongodb';
1
+ import { ObjectId } from 'mongodb';
2
2
  import type { ActingFor } from './identity';
3
+ import { LEASE_MS, MAX_ATTEMPTS, RENEW_MS } from './formflow';
4
+ import type { CoreNewTaskRun, CoreTaskRunRecord, CoreTaskRunStore, TaskRunStatus } from './formflow';
3
5
  /**
4
6
  * Run task records in the UAG's database (`uag-taskruns`): one per run, for
5
7
  * audit (Action Logs), and, for runs that act with an agent role, the queue
6
8
  * that lets a run survive a restart and be taken by one replica at a time.
7
9
  * A record never holds a token.
10
+ *
11
+ * The store is Agentic Formflow's (`useDatabase`); the UAG gives it the
12
+ * collection and its ids.
8
13
  */
9
- export type TaskRunStatus = 'pending' | 'running' | 'done' | 'failed';
10
- export type TaskRunRecord = {
11
- _id: ObjectId;
12
- project: string;
13
- /** The form's id, and its name for the runner. */
14
- form: string;
15
- formName: string;
16
- submission?: string;
17
- /** The action's title, and the task it runs. */
18
- action: string;
19
- task: string;
20
- answer: 'decisions' | 'task';
21
- /** When it ran: before the save, or after it. */
22
- handler: 'before' | 'after';
23
- method: string;
24
- /** Resumed after a restart (it acts with an agent role). */
25
- durable: boolean;
14
+ export type { TaskRunStatus };
15
+ export type TaskRunRecord = CoreTaskRunRecord<ObjectId> & {
26
16
  actingFor: ActingFor;
27
- /** The X-Request-Id of the request that started the run. */
28
- requestId?: string;
29
- agentRole?: string;
30
- /** How many runner saves led to this run. */
31
- chain?: number;
32
- status: TaskRunStatus;
33
- attempts: number;
34
- owner?: string;
35
- leaseUntil?: Date;
36
- engines?: {
37
- kind: string;
38
- engine: string | null;
39
- model: string | null;
40
- }[];
41
- written?: string[];
42
- cleared?: string[];
43
- saved?: boolean;
44
- error?: string;
45
- created: Date;
46
- modified: Date;
47
- started?: Date;
48
- finished?: Date;
49
17
  };
50
- export type NewTaskRun = Omit<TaskRunRecord, '_id' | 'attempts' | 'created' | 'modified' | 'status'> & {
51
- status?: TaskRunStatus;
18
+ export type NewTaskRun = CoreNewTaskRun<ObjectId> & {
19
+ actingFor: ActingFor;
20
+ };
21
+ export type TaskRunStore = CoreTaskRunStore<ObjectId>;
22
+ export { LEASE_MS, MAX_ATTEMPTS, RENEW_MS };
23
+ /** The ids of the UAG's database: MongoDB ObjectIds. */
24
+ export declare const OBJECT_IDS: {
25
+ create: () => ObjectId;
26
+ parse: (id: string) => ObjectId | null;
52
27
  };
53
- /** How long a replica holds a run before another may take it over, and how often it renews. */
54
- export declare const LEASE_MS: number;
55
- export declare const RENEW_MS: number;
56
- /** Attempts before a run that keeps losing its replica is given up. */
57
- export declare const MAX_ATTEMPTS = 3;
58
- export declare class TaskRunStore {
59
- readonly collection: Collection<TaskRunRecord>;
60
- readonly project?: string | undefined;
61
- /** This replica, as the owner of the runs it holds. */
62
- readonly owner: `${string}-${string}-${string}-${string}-${string}`;
63
- /**
64
- * `project` limits the durable runs this replica takes to its project's:
65
- * UAGs of different projects may share one database.
66
- */
67
- constructor(collection: Collection<TaskRunRecord>, project?: string | undefined);
68
- private get scope();
69
- add(run: NewTaskRun): Promise<ObjectId>;
70
- /**
71
- * Take the oldest durable run that is pending, or whose holder's lease ran
72
- * out, for this replica. Only one replica can take a run.
73
- */
74
- claim(now?: Date): Promise<TaskRunRecord | null>;
75
- /** Fail the runs that lost their replica too many times. */
76
- private giveUp;
77
- /** Keep holding a run. False when another replica took it over. */
78
- renew(id: ObjectId): Promise<boolean>;
79
- /** Record how a run ended. Only the replica holding a durable run can. */
80
- finish(id: ObjectId, result: Pick<TaskRunRecord, 'engines' | 'written' | 'cleared' | 'saved' | 'error'> & {
81
- status: 'done' | 'failed';
82
- }): Promise<void>;
83
- get(id: string): Promise<TaskRunRecord | null>;
84
- /** The project's records matching a filter, newest change first. */
85
- list(filter: Record<string, any>, limit: number): Promise<{
86
- items: TaskRunRecord[];
87
- total: number;
88
- }>;
89
- }
package/lib/runs.js CHANGED
@@ -1,74 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.TaskRunStore = exports.MAX_ATTEMPTS = exports.RENEW_MS = exports.LEASE_MS = void 0;
4
- const crypto_1 = require("crypto");
3
+ exports.OBJECT_IDS = exports.RENEW_MS = exports.MAX_ATTEMPTS = exports.LEASE_MS = void 0;
5
4
  const mongodb_1 = require("mongodb");
6
- /** How long a replica holds a run before another may take it over, and how often it renews. */
7
- exports.LEASE_MS = 2 * 60 * 1000;
8
- exports.RENEW_MS = 30 * 1000;
9
- /** Attempts before a run that keeps losing its replica is given up. */
10
- exports.MAX_ATTEMPTS = 3;
11
- class TaskRunStore {
12
- /**
13
- * `project` limits the durable runs this replica takes to its project's:
14
- * UAGs of different projects may share one database.
15
- */
16
- constructor(collection, project) {
17
- this.collection = collection;
18
- this.project = project;
19
- /** This replica, as the owner of the runs it holds. */
20
- this.owner = (0, crypto_1.randomUUID)();
21
- }
22
- get scope() {
23
- return this.project === undefined ? {} : { project: this.project };
24
- }
25
- async add(run) {
26
- const now = new Date();
27
- const record = { ...run, _id: new mongodb_1.ObjectId(), status: run.status || 'pending', attempts: 0, created: now, modified: now };
28
- await this.collection.insertOne(record);
29
- return record._id;
30
- }
31
- /**
32
- * Take the oldest durable run that is pending, or whose holder's lease ran
33
- * out, for this replica. Only one replica can take a run.
34
- */
35
- async claim(now = new Date()) {
36
- await this.giveUp(now);
37
- return this.collection.findOneAndUpdate({
38
- ...this.scope,
39
- durable: true,
40
- attempts: { $lt: exports.MAX_ATTEMPTS },
41
- $or: [{ status: 'pending' }, { status: 'running', leaseUntil: { $lt: now } }]
42
- }, { $set: { status: 'running', owner: this.owner, leaseUntil: new Date(now.getTime() + exports.LEASE_MS), started: now, modified: now }, $inc: { attempts: 1 } }, { sort: { created: 1 }, returnDocument: 'after' });
43
- }
44
- /** Fail the runs that lost their replica too many times. */
45
- async giveUp(now) {
46
- await this.collection.updateMany({ ...this.scope, durable: true, status: 'running', leaseUntil: { $lt: now }, attempts: { $gte: exports.MAX_ATTEMPTS } }, { $set: { status: 'failed', error: `The run was given up after ${exports.MAX_ATTEMPTS} attempts.`, finished: now, modified: now }, $unset: { leaseUntil: '', owner: '' } });
47
- }
48
- /** Keep holding a run. False when another replica took it over. */
49
- async renew(id) {
50
- const now = new Date();
51
- const result = await this.collection.updateOne({ _id: id, owner: this.owner, status: 'running' }, { $set: { leaseUntil: new Date(now.getTime() + exports.LEASE_MS), modified: now } });
52
- return result.matchedCount > 0;
53
- }
54
- /** Record how a run ended. Only the replica holding a durable run can. */
55
- async finish(id, result) {
56
- const now = new Date();
57
- await this.collection.updateOne({ _id: id, $or: [{ durable: false }, { owner: this.owner }] }, { $set: { ...result, finished: now, modified: now }, $unset: { leaseUntil: '', owner: '' } });
58
- }
59
- async get(id) {
60
- if (!mongodb_1.ObjectId.isValid(id))
61
- return null;
62
- return this.collection.findOne({ _id: new mongodb_1.ObjectId(id) });
63
- }
64
- /** The project's records matching a filter, newest change first. */
65
- async list(filter, limit) {
66
- const [items, total] = await Promise.all([
67
- // A limit of 0 would be none at all to MongoDB.
68
- limit > 0 ? this.collection.find(filter).sort({ modified: -1 }).limit(limit).toArray() : Promise.resolve([]),
69
- this.collection.countDocuments(filter)
70
- ]);
71
- return { items, total };
72
- }
73
- }
74
- exports.TaskRunStore = TaskRunStore;
5
+ const formflow_1 = require("./formflow");
6
+ Object.defineProperty(exports, "LEASE_MS", { enumerable: true, get: function () { return formflow_1.LEASE_MS; } });
7
+ Object.defineProperty(exports, "MAX_ATTEMPTS", { enumerable: true, get: function () { return formflow_1.MAX_ATTEMPTS; } });
8
+ Object.defineProperty(exports, "RENEW_MS", { enumerable: true, get: function () { return formflow_1.RENEW_MS; } });
9
+ /** The ids of the UAG's database: MongoDB ObjectIds. */
10
+ exports.OBJECT_IDS = {
11
+ create: () => new mongodb_1.ObjectId(),
12
+ parse: (id) => (mongodb_1.ObjectId.isValid(id) ? new mongodb_1.ObjectId(id) : null)
13
+ };
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @formio/uag/testing: test a module without a Form.io project or a running UAG.
3
+ *
4
+ * const { createTestModuleApi, testForm, testAuth, mountModuleRoutes } = require('@formio/uag/testing');
5
+ *
6
+ * const api = await createTestModuleApi(MyModule, { config: { myModule: { max: 2 } } });
7
+ * const app = await mountModuleRoutes(MyModule, { form: testForm(definition), auth: testAuth() });
8
+ * await supertest(app).post('/notes/my-module/check').send({ ... });
9
+ *
10
+ * The API is the UAG's own (errors, stream, forms, converters, engine handles,
11
+ * Markdown, callerKey), with stand-ins only for what needs a project: the
12
+ * caller's access (from `testAuth`), the license (`licensed`), engines
13
+ * (`engines`) and stored files (`testForm` reads base64 values; `files` for
14
+ * others). Settings go through the module's own `settings`, with the server's rules.
15
+ */
16
+ import express from 'express';
17
+ import type { AuthRequest, ModuleForm, ServerModule } from '@formio/appserver';
18
+ import type { UAGModuleApi } from './modules/types';
19
+ /** An engine for tests: an agent engine that generates. */
20
+ export type TestEngine = {
21
+ name: string;
22
+ model?: string;
23
+ accepts?: Array<'text' | 'pdf'>;
24
+ generate(request: any): any;
25
+ kind?: 'agent';
26
+ };
27
+ export type TestModuleOptions = {
28
+ /** The settings as given, instead of running the module's `settings`. */
29
+ settings?: Record<string, any>;
30
+ /** The configuration and environment the module's `settings` gets ({} by default). */
31
+ config?: Record<string, any>;
32
+ env?: Record<string, string | undefined>;
33
+ /** Plugins to register beside the module's own (document plugins, converters). */
34
+ plugins?: any[];
35
+ /** The engines `api.engines.select()` chooses from. */
36
+ engines?: TestEngine[];
37
+ /** Whether Agentic Formflow is licensed (true): false makes `requireLicense` and engine handles' `generate` refuse with 403. */
38
+ licensed?: boolean;
39
+ /** Reads a stored file value that is not base64 (the test form reads those). */
40
+ files?: (value: any, options: {
41
+ component?: any;
42
+ maxBytes?: number;
43
+ }) => Buffer | null | Promise<Buffer | null>;
44
+ };
45
+ /** A form for tests, as module routes get it: `definition` is the form's JSON. */
46
+ export declare function testForm(definition: Record<string, any>, options?: {
47
+ id?: string;
48
+ files?: TestModuleOptions['files'];
49
+ }): ModuleForm & {
50
+ readFile(value: any, auth: any, options?: any): Promise<Buffer | null>;
51
+ };
52
+ /**
53
+ * A caller for tests: a user with these permissions on every form (create and
54
+ * update by default), as a list (`['read']`) or as `formPermissions()` gives
55
+ * them (`{ read: true }`).
56
+ */
57
+ export declare function testAuth(options?: {
58
+ user?: Record<string, any>;
59
+ permissions?: Array<'create' | 'read' | 'update' | 'delete'> | Partial<Record<'create' | 'read' | 'update' | 'delete', boolean>>;
60
+ admin?: boolean;
61
+ }): AuthRequest;
62
+ /** The UAG's module API for a module, in a test. */
63
+ export declare function createTestModuleApi<Settings extends Record<string, any> = Record<string, any>>(module: ServerModule<any>, options?: TestModuleOptions): Promise<UAGModuleApi<Settings>>;
64
+ /**
65
+ * The module's form routes on an Express app, as the UAG mounts them on a
66
+ * form (by its path, and by `form/<id>`), for supertest: with the form, the
67
+ * caller, a request id, the access check and the UAG's error shape. No rate
68
+ * limits. `api` is created with `options` unless given.
69
+ */
70
+ export declare function mountModuleRoutes(module: ServerModule<any>, options: TestModuleOptions & {
71
+ form: ReturnType<typeof testForm>;
72
+ auth?: AuthRequest;
73
+ api?: UAGModuleApi;
74
+ }): Promise<express.Express>;