@saltcorn/copilot 0.8.2 → 0.8.3

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.
@@ -65,13 +65,17 @@ class GeneratePage {
65
65
  };
66
66
  }
67
67
  static async system_prompt() {
68
- return `If the user asks to generate a page, a web page or a landing page, use the generate_page to generate a page.`;
68
+ return `If the user asks to generate a page, a web page or a landing page, use the generate_page to generate a page.
69
+
70
+ Critical: When generating HTML, your response must be the complete, final HTML document and nothing else. Do not include reasoning, planning notes, explanatory text, TODO comments, markdown code fences, or placeholder content. Everything you write will be stored verbatim and rendered directly to users — there is no review step between your output and the live page.`;
69
71
  }
70
72
  static async follow_on_generate({ name, page_type }) {
71
73
  if (page_type === "Marketing page") {
72
74
  return {
73
75
  prompt:
74
- "Generate the HTML for the web page using the Bootstrap 5 CSS framework.",
76
+ "Generate the HTML for the web page using the Bootstrap 5 CSS framework. " +
77
+ 'Include a toast notification area just before the closing </body> tag: ' +
78
+ '<div id="toasts-area" class="toast-container position-fixed top-0 start-50 p-0" style="z-index:999;" aria-live="polite" aria-atomic="true"></div>',
75
79
  };
76
80
  }
77
81
  const prompt = `Now generate the contents of the ${name} page`;
@@ -156,6 +156,11 @@ class GenerateTables {
156
156
  type: "string",
157
157
  description: "The name of the table",
158
158
  },
159
+ description: {
160
+ type: "string",
161
+ description:
162
+ "A short human-readable description of what this table stores and its role in the application",
163
+ },
159
164
  fields: {
160
165
  type: "array",
161
166
  items: this.field_item_schema(),
@@ -263,6 +268,28 @@ class GenerateTables {
263
268
  The type_and_configuration.data_type for a calculated field should reflect the return type of
264
269
  the expression (e.g. Integer, Float, String, Bool).
265
270
 
271
+ ## Table descriptions
272
+
273
+ Every table you define MUST include a description — a short sentence explaining what
274
+ the table stores and its role in the application. This description is shown in
275
+ subsequent prompts to give the planner context about the schema, so make it
276
+ informative (e.g. "Stores billable time entries logged by lawyers against a project").
277
+
278
+ ## Bulk data import and export
279
+
280
+ Do NOT create tables whose purpose is to trigger a bulk import or export (e.g. a
281
+ table with a File field that a user fills in via an Edit view to start an import).
282
+ Bulk import and export is a UI concern — there are plugins that provide dedicated
283
+ viewtemplates operating directly on the target table, which is a much better
284
+ solution than a workaround table with a file field and an Edit view.
285
+
286
+ A tracking table that records the status and outcome of an automated import or
287
+ export process (e.g. import_jobs) is acceptable, but only if the table is populated
288
+ automatically by the process — not filled in manually by a user. Such tables must
289
+ have a description that clearly says they are auto-populated and must not be edited
290
+ by hand (e.g. "Auto-populated by the import process. Records status and errors for
291
+ each import run. Not editable by users.").
292
+
266
293
  ## Existing tables
267
294
 
268
295
  The database already contains the following tables:
@@ -292,8 +319,10 @@ class GenerateTables {
292
319
  }
293
320
 
294
321
  static async execute({ tables }, req) {
295
- const sctables = this.process_tables(tables);
296
- for (const table of sctables) await Table.create(table.name);
322
+ const existingDbTables = await Table.find({});
323
+ const sctables = this.process_tables(tables, existingDbTables);
324
+ for (const table of sctables)
325
+ await Table.create(table.name, { description: table.description || "" });
297
326
  for (const table of sctables) {
298
327
  for (const field of table.fields) {
299
328
  field.table = Table.findOne({ name: table.name });
@@ -322,10 +351,11 @@ class GenerateTables {
322
351
  const added = [];
323
352
  const updated = [];
324
353
 
354
+ const existingDbTables = await Table.find({});
325
355
  for (const f of sanitized) {
326
356
  const fname = (f?.name || "").toLowerCase();
327
357
  if (!fname) continue;
328
- const processed = this.process_field(f, []);
358
+ const processed = this.process_field(f, [], existingDbTables);
329
359
  const existing = existingFieldMap.get(fname);
330
360
  if (!existing) {
331
361
  processed.table = table;
@@ -359,7 +389,7 @@ class GenerateTables {
359
389
  return { added, updated };
360
390
  }
361
391
 
362
- static process_field(f, allTablesList = []) {
392
+ static process_field(f, allTablesList = [], dbTables = []) {
363
393
  if (f.aggregation) {
364
394
  const { data_type } = f.type_and_configuration || {
365
395
  data_type: "Integer",
@@ -387,6 +417,8 @@ class GenerateTables {
387
417
  f.type_and_configuration || { data_type: "String" };
388
418
  let type = data_type;
389
419
  const scattributes = { ...attributes, importance: f.importance };
420
+ if (!scattributes.min_length) delete scattributes.min_length;
421
+ if (!scattributes.max_length) delete scattributes.max_length;
390
422
  if (data_type === "ForeignKey") {
391
423
  type = `Key to ${reference_table}`;
392
424
  const refTableHere = allTablesList.find(
@@ -404,6 +436,21 @@ class GenerateTables {
404
436
  }
405
437
  } else if (reference_table === "users") {
406
438
  scattributes.summary_field = "email";
439
+ } else {
440
+ const dbTable = dbTables.find((t) => t.name === reference_table);
441
+ if (dbTable) {
442
+ const strFields = (dbTable.fields || []).filter(
443
+ (rf) => rf.type?.name === "String" || rf.type === "String"
444
+ );
445
+ if (strFields.length) {
446
+ const maxImp = strFields.reduce((prev, curr) => {
447
+ const pi = prev?.attributes?.importance ?? 0;
448
+ const ci = curr?.attributes?.importance ?? 0;
449
+ return pi >= ci ? prev : curr;
450
+ });
451
+ scattributes.summary_field = maxImp.name;
452
+ }
453
+ }
407
454
  }
408
455
  }
409
456
  return {
@@ -417,14 +464,17 @@ class GenerateTables {
417
464
  };
418
465
  }
419
466
 
420
- static process_tables(tables) {
467
+ static process_tables(tables, dbTables = []) {
421
468
  return tables.map((table) => {
422
469
  const sanitizedFields = Array.isArray(table.fields)
423
470
  ? table.fields.filter((f) => (f?.name || "").toLowerCase() !== "id")
424
471
  : [];
425
472
  return new Table({
426
473
  name: table.table_name,
427
- fields: sanitizedFields.map((f) => this.process_field(f, tables)),
474
+ description: table.description || "",
475
+ fields: sanitizedFields.map((f) =>
476
+ this.process_field(f, tables, dbTables)
477
+ ),
428
478
  });
429
479
  });
430
480
  }
@@ -272,6 +272,16 @@ class GenerateWorkflow {
272
272
  IMPORTANT — keep row expressions simple; use dedicated steps for data fetching:
273
273
  Row expressions (e.g. in modify_row) should be simple references to values already in the context — not inline queries or complex logic. If the values you need are not already in context, add a dedicated step before the row expression step to fetch or compute them and write the results into the context. Choose the step type that fits the job: TableQuery to query a table, run_js_code for custom computation, or any other appropriate step. Each step should do one clear thing; the row expression then just picks the relevant context values.
274
274
 
275
+ CRITICAL — modify_row without a triggering row requires where="Database" + select_table + query (all three):
276
+ A basic modify_row step updates the row that triggered the workflow. If the workflow is NOT triggered by a specific table row (e.g. triggered by a button with no table, or running inside a ForLoop), there is no triggering row and table is undefined — this crashes with "Cannot read properties of undefined (reading 'tryUpdateRow')".
277
+ To update arbitrary rows in a workflow, the step configuration MUST include all three of:
278
+ - where: "Database"
279
+ - select_table: the name of the table to update (string)
280
+ - query: a JS expression (evaluated against the workflow context) returning a where-clause object that identifies the row(s) to update — e.g. {id: bh_id}
281
+ If any of these three is missing, the action falls through to the triggering-row path and crashes when table is undefined.
282
+ Example modify_row step configuration for updating a billable_hours row inside a ForLoop where bh_id is in context:
283
+ { "step_type": "modify_row", "where": "Database", "select_table": "billable_hours", "query": "{id: bh_id}", "row_expr": "{invoiced: true}" }
284
+
275
285
  CRITICAL — every workflow must form a single connected chain from the first step to the last:
276
286
  - Every step except the very last one MUST have a next_step that names another step in the workflow.
277
287
  - The very last step must have next_step omitted or set to an empty string to terminate the workflow.
@@ -282,6 +292,20 @@ class GenerateWorkflow {
282
292
  steps can read values from and write values to. This context is a state that is persisted on disk for each workflow
283
293
  run.
284
294
 
295
+ Important: every {{}} interpolation in any workflow step (email body, email subject, filename, prompt template,
296
+ etc.) must reference a variable that already exists in the workflow context at the point the step runs.
297
+ Do NOT use fallback expressions such as {{invoice_date || new Date().toISOString()}} — if the variable is not
298
+ defined, the interpolation engine throws before the || fallback can execute. For example, a send_email step
299
+ using {{invoice_date}} will fail with "invoice_date is not defined" if no prior step put invoice_date in context.
300
+ If a value might not be in context at that point, retrieve or compute it in an earlier step and store it under
301
+ a known key.
302
+
303
+ Important: when a workflow is triggered from a Show view action button, the trigger must have its table set to
304
+ the view's table. Saltcorn then automatically passes the full row as the initial workflow context — all field
305
+ values are available by their field names (e.g. id, name, contact_email). Do NOT attempt to pass row data
306
+ through a state property on the actions array — it is silently ignored. If the trigger has no table set, the
307
+ workflow starts with an empty context and all field references will throw "is not defined".
308
+
285
309
  Each step can have a next_step key which is the name of the next step, or a JavaScript expression which evaluates
286
310
  to the name of the next step based on the context. In the evaluation of the next step, each value in the context is
287
311
  in scope and can be addressed directly. Identifiers for the step names are also in scope, the name of the next step
@@ -307,16 +331,43 @@ class GenerateWorkflow {
307
331
  Most of them are are explained by their parameter descriptions. Here are some additional information for some
308
332
  step types:
309
333
 
334
+ TableQuery: stores the query results in the context under the name given in the query_variable configuration field.
335
+ This name is chosen by you — pick a short descriptive name (e.g. "billable_hours", "lawyer_rows").
336
+ Every subsequent step that reads those results — whether a run_js_code step or another TableQuery's query_object
337
+ expression — must use this exact variable name. Mismatched names cause "X is not defined" errors at runtime.
338
+
339
+ Important: an insert step writes ONLY the new row's id into the context (e.g. new_invoice_id). It does NOT
340
+ write any other field values from the inserted row. If a later step needs fields from that row (e.g. invoice_date,
341
+ total_amount, contact_email), add a TableQuery step immediately after the insert to fetch the row by id and make
342
+ its fields available in context. For example, query {id: new_invoice_id} on the invoices table with
343
+ query_variable "invoice_row", then reference invoice_row.invoice_date, invoice_row.total_amount etc. in
344
+ subsequent steps. Never assume that insert fields are in context — only the id is guaranteed.
345
+
310
346
  run_js_code: if the step_type is "run_js_code" then the step object should include the JavaScript code to be executed in the "code"
311
347
  key. You can use await in the code if you need to run asynchronous code. The values in the context are directly in scope and can be accessed using their name. In addition, the variable
312
348
  "context" is also in scope and can be used to address the context as a whole. To write values to the context, return an
313
349
  object. The following Saltcorn models are already available in scope without any require: Table, Row, Field, User, View, Trigger, Page, File — use them directly.
314
350
  When you need to require other modules, always use a plain require call, e.g. const moment = require('moment');
315
- NEVER use the pattern const X = X || require(...) — this causes a ReferenceError because const variables cannot be referenced before initialization. The values in this object will be written into the current context. If a value already exists in the context
351
+ NEVER use the pattern const X = X || require(...) — this causes a ReferenceError because const variables cannot be referenced before initialization.
352
+ The values in this object will be written into the current context. If a value already exists in the context
316
353
  it will be overwritten. For example, If the context contains values x and y which are numbers and you would like to push
317
- the value "sum" which is the sum of x and y, then use this as the code: return {sum: x+y}. You cannot set the next step in the
354
+ the value "sum" which is the sum of x and y, then use this as the code: return {sum: x+y}. You cannot set the next step in the
318
355
  return object or by returning a string from a run_js_code step, this will not work. To set the next step from a code action, always use the next_step property of the step object.
319
- This expression for the next step can depend on value pushed to the context (by the return object in the code) as these values are in scope.
356
+ This expression for the next step can depend on value pushed to the context (by the return object in the code) as these values are in scope.
357
+
358
+ SetContext: sets one or more variables in the workflow context. The configuration must include a ctx_values key
359
+ whose value is a JavaScript object expression — it MUST be wrapped in curly braces.
360
+ Example: {running_total: 0} initialises a counter, {invoice_date: new Date().toISOString().slice(0,10)} sets a date.
361
+ Do NOT write bare key-value pairs such as running_total: 0 — without the braces the expression is invalid and
362
+ throws "running_total is not defined" when a later step tries to read it.
363
+ The object expression can reference existing context variables: {total: subtotal + tax}.
364
+
365
+ Early termination pattern: to stop a workflow when a condition is not met (e.g. no rows found),
366
+ use a run_js_code step that writes a boolean flag to the context (e.g. return {has_rows: billable_hours.length > 0}),
367
+ then set that step's next_step to a conditional expression (e.g. has_rows ? "next_real_step" : "abort_step"),
368
+ and add a TerminateWorkflow step named "abort_step".
369
+ Do NOT try to terminate from inside run_js_code by returning null, throwing, or omitting a return — those do not stop the workflow.
370
+ The conditional must be in the step's next_step field, not inside the code.
320
371
 
321
372
  ForLoop: ForLoop steps loop over an array which is specified by the array_expression JavaScript expression. Execution of the workflow steps is temporarily diverted to another set
322
373
  of steps, starting from the step specified by the loop_body_inital_step value, and runs until it encounters a
@@ -60,21 +60,51 @@ class GeneratePageSkill {
60
60
  html,
61
61
  min_role = 100,
62
62
  }) {
63
- const file = await File.from_contents(
64
- `${name}.html`,
65
- "text/html",
66
- html,
67
- user?.id,
68
- min_role
69
- );
70
-
71
- await Page.create({
72
- name,
73
- title,
74
- description,
75
- min_role,
76
- layout: { html_file: file.path_to_serve },
77
- });
63
+ const existingPage = Page.findOne({ name });
64
+ if (existingPage) {
65
+ // Overwrite existing file if the page already has one, otherwise create new
66
+ let filePath;
67
+ if (existingPage.layout?.html_file) {
68
+ const existingFile = await File.findOne(
69
+ existingPage.layout.html_file
70
+ );
71
+ if (existingFile) {
72
+ await existingFile.overwrite_contents(html);
73
+ filePath = existingFile.path_to_serve;
74
+ }
75
+ }
76
+ if (!filePath) {
77
+ const file = await File.from_contents(
78
+ `${name}.html`,
79
+ "text/html",
80
+ html,
81
+ user?.id,
82
+ min_role
83
+ );
84
+ filePath = file.path_to_serve;
85
+ }
86
+ await Page.update(existingPage.id, {
87
+ title,
88
+ description,
89
+ min_role,
90
+ layout: { html_file: filePath },
91
+ });
92
+ } else {
93
+ const file = await File.from_contents(
94
+ `${name}.html`,
95
+ "text/html",
96
+ html,
97
+ user?.id,
98
+ min_role
99
+ );
100
+ await Page.create({
101
+ name,
102
+ title,
103
+ description,
104
+ min_role,
105
+ layout: { html_file: file.path_to_serve },
106
+ });
107
+ }
78
108
  setTimeout(() => getState().refresh_pages(), 200);
79
109
  return {
80
110
  notify: `Page saved: <a target="_blank" href="/page/${name}">${name}</a>`,
@@ -104,62 +134,130 @@ class GeneratePageSkill {
104
134
  } else return "Metadata recieved";
105
135
  },
106
136
  postProcess: async ({ tool_call, generate, req }) => {
107
- const str = await generate(
108
- `Now generate the contents of the ${tool_call.input.name} HTML page. If I asked you to embed a view,
109
- use the <embed-view> self-closing tag to do so, setting the view name in the viewname attribute. For example,
137
+ const version_tag = db.connectObj.version_tag;
138
+ const asset = (name) => `/static_assets/${version_tag}/${name}`;
139
+ const { name, title, description, min_role, page_type } =
140
+ tool_call.input;
141
+
142
+ if (page_type === "Layout page") {
143
+ const str = await generate(
144
+ `Generate the Saltcorn layout JSON for the ${name} page. ` +
145
+ `Your response must be a single valid JSON object — no explanatory text, no markdown fences, no reasoning. ` +
146
+ `\n\nThe layout is a tree of segments. The top-level object is:\n` +
147
+ ` {"above": [...segments...]} — stack items vertically\n` +
148
+ `or:\n` +
149
+ ` {"besides": [...segments...], "widths": [6,6]} — place side by side (widths must sum to 12)\n` +
150
+ `\nAvailable segment types:\n` +
151
+ `- Embed a view (receives page URL state, e.g. id): {"type":"view","view":"viewname","state":"shared"}\n` +
152
+ ` Use state "shared" whenever the embedded view needs variables from the page URL (such as id for a Show view).\n` +
153
+ ` To pass only specific variables, add extra_state_fml: {"type":"view","view":"viewname","state":"shared","extra_state_fml":"{id: id}"}\n` +
154
+ `- HTML block: {"type":"blank","isHTML":true,"contents":"<p>html</p>"}\n` +
155
+ `- Text block: {"type":"blank","contents":"plain text"}\n` +
156
+ `- Container/wrapper: {"type":"container","customClass":"p-3","htmlElement":"div","contents":{segment}}\n` +
157
+ `- Vertical stack: {"above":[{segment},{segment}]}\n` +
158
+ `- Horizontal split: {"besides":[{segment},{segment}],"widths":[8,4]}\n`
159
+ );
160
+ const jsonStr = str.includes("```json")
161
+ ? str.split("```json")[1].split("```")[0]
162
+ : str.includes("```")
163
+ ? str.split("```")[1].split("```")[0]
164
+ : str;
165
+ let layout;
166
+ try {
167
+ layout = JSON.parse(jsonStr.trim());
168
+ } catch (e) {
169
+ return {
170
+ stop: true,
171
+ add_response: `Error parsing layout JSON for ${name}: ${e.message}`,
172
+ };
173
+ }
174
+ if (this.yoloMode) {
175
+ const existingPage = await Page.findOne({ name });
176
+ if (existingPage) {
177
+ await Page.update(existingPage.id, {
178
+ title,
179
+ description,
180
+ min_role: min_role ?? 100,
181
+ layout,
182
+ });
183
+ } else {
184
+ await Page.create({
185
+ name,
186
+ title,
187
+ description,
188
+ min_role: min_role ?? 100,
189
+ layout,
190
+ });
191
+ }
192
+ setTimeout(() => getState().refresh_pages(), 200);
193
+ return {
194
+ stop: true,
195
+ add_response: `Page ${name} created.`,
196
+ };
197
+ }
198
+ return {
199
+ stop: true,
200
+ add_response: `Page ${name} layout generated (preview not available for layout pages).`,
201
+ };
202
+ } else {
203
+ const str = await generate(
204
+ `Generate the complete HTML for the ${name} page. Your response must be the raw HTML document only — no explanatory text before or after, no markdown code fences, no reasoning, no placeholder comments. The output is saved and rendered directly to users without any review step, so anything you write outside valid HTML will be visible on the page.
205
+
206
+ If I asked you to embed a view, use the <embed-view> self-closing tag to do so, setting the view name in the viewname attribute. For example,
110
207
  to embed the view LeadForm inside a div, write: <div><embed-view viewname="LeadForm"></div>
111
-
208
+
112
209
  If you need to include the standard bootstrap CSS and javascript files, they are available as:
113
210
 
114
211
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
115
212
 
116
- and
117
- <script src="/static_assets/js/jquery-3.6.0.min.js"></script>
213
+ and
214
+ <script src="${asset("jquery-3.6.0.min.js")}"></script>
118
215
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
119
216
 
120
217
  If you are embedding views with <embed-view>, you should also embed the following script sources at the end of the <body> tag to make sure the content inside those views works:
121
218
 
122
- <script src="/static_assets/js/saltcorn-common.js"></script>
123
- <script src="/static_assets/js/saltcorn.js">
219
+ <script src="${asset("saltcorn-common.js")}"></script>
220
+ <script src="${asset("saltcorn.js")}"></script>
124
221
  `
125
- );
126
- const html = str.includes("```html")
127
- ? str.split("```html")[1].split("```")[0]
128
- : str;
129
-
130
- if (this.yoloMode) {
131
- await this.userActions.build_copilot_page_gen({
132
- user: req?.user,
133
- name: tool_call.input.name,
134
- title: tool_call.input.title,
135
- description: tool_call.input.description,
136
- min_role: tool_call.input.min_role ?? 100,
137
- html,
138
- });
222
+ );
223
+ const html = str.includes("```html")
224
+ ? str.split("```html")[1].split("```")[0]
225
+ : str;
226
+
227
+ if (this.yoloMode) {
228
+ await this.userActions.build_copilot_page_gen({
229
+ user: req?.user,
230
+ name,
231
+ title,
232
+ description,
233
+ min_role: min_role ?? 100,
234
+ html,
235
+ });
236
+ return {
237
+ stop: true,
238
+ add_response: `Page ${name} created.`,
239
+ };
240
+ }
139
241
  return {
140
242
  stop: true,
141
- add_response: `Page ${tool_call.input.name} created.`,
142
- };
143
- }
144
- return {
145
- stop: true,
146
- add_response: iframe({
147
- srcdoc: text_attr(html),
148
- width: 500,
149
- height: 800,
150
- }),
151
- add_system_prompt: `If the user asks you to regenerate the page,
243
+ add_response: iframe({
244
+ srcdoc: text_attr(html),
245
+ width: 500,
246
+ height: 800,
247
+ }),
248
+ add_system_prompt: `If the user asks you to regenerate the page,
152
249
  you must run the generate_page tool again. After running this tool
153
250
  you will be prompted to generate the html again. You should repeat
154
251
  the html from the previous answer except for the changes the user
155
252
  is requesting.`,
156
- add_user_action: {
157
- name: "build_copilot_page_gen",
158
- type: "button",
159
- label: "Save page " + tool_call.input.name,
160
- input: { html },
161
- },
162
- };
253
+ add_user_action: {
254
+ name: "build_copilot_page_gen",
255
+ type: "button",
256
+ label: "Save page " + name,
257
+ input: { html },
258
+ },
259
+ };
260
+ }
163
261
  },
164
262
 
165
263
  /*renderToolCall({ phrase }, { req }) {
@@ -210,9 +308,9 @@ class GeneratePageSkill {
210
308
  },
211
309
  page_type: {
212
310
  description:
213
- "The type of page to generate: a Marketing page if for promotional purposes, such as a landing page or a brouchure, with an appealing design. An Application page is simpler and an integrated part of the application",
311
+ "The type of page to generate. Use 'Layout page' for any page that is part of the application (dashboards, print pages, data pages, pages that embed views) — this creates a proper Saltcorn layout page. Use 'Marketing page' for public-facing promotional pages such as landing pages or brochures. Use 'Application page' only for standalone HTML pages that are part of the app but do not embed Saltcorn views.",
214
312
  type: "string",
215
- enum: ["Marketing page", "Application page"],
313
+ enum: ["Layout page", "Marketing page", "Application page"],
216
314
  },
217
315
  },
218
316
  },
@@ -376,6 +376,18 @@ with both the entity type and name, and the new JSON definition as a string as a
376
376
  const trigger = Trigger.findOne({ name: input.entity_name });
377
377
  if (!trigger) return `trigger not found`;
378
378
  const value = trigger.toJson;
379
+ // For workflow triggers, attach all steps so the agent can see their configurations
380
+ if (trigger.action === "Workflow") {
381
+ const steps = await WorkflowStep.find({ trigger_id: trigger.id });
382
+ value.steps = steps.map((s) => ({
383
+ id: s.id,
384
+ name: s.name,
385
+ action_name: s.action_name,
386
+ initial_step: s.initial_step,
387
+ next_step: s.next_step,
388
+ configuration: s.configuration,
389
+ }));
390
+ }
379
391
  const schema = {
380
392
  name: { type: "string", description: "trigger name" },
381
393
  action: {
@@ -726,8 +738,9 @@ with both the entity type and name, and the new JSON definition as a string as a
726
738
  table || table_name,
727
739
  )?.id;
728
740
 
729
- // Pre-check action before saving
730
- if (tsNoTableName.action) {
741
+ // Pre-check action before saving (skip built-ins handled outside actions registry)
742
+ const builtinActions = new Set(["Workflow", "Multi-step action"]);
743
+ if (tsNoTableName.action && !builtinActions.has(tsNoTableName.action)) {
731
744
  const action = getState().actions[tsNoTableName.action];
732
745
  if (!action)
733
746
  return `Action '${tsNoTableName.action}' not found`;
@@ -1,5 +1,11 @@
1
1
  const viewname = "Saltcorn AppConstructor (experimental)";
2
2
 
3
+ const TaskType = Object.freeze({
4
+ PLUGIN: "plugin",
5
+ DATA_MODEL: "data_model",
6
+ FEATURE: "feature",
7
+ });
8
+
3
9
  const tool_choice = (tool_name) => ({
4
10
  tool_choice: {
5
11
  type: "function",
@@ -9,4 +15,4 @@ const tool_choice = (tool_name) => ({
9
15
  },
10
16
  });
11
17
 
12
- module.exports = { viewname, tool_choice };
18
+ module.exports = { viewname, tool_choice, TaskType };