loopengine 0.2.2 → 0.3.1

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.
@@ -15,6 +15,11 @@ import { ActauthRuleExistsError, addActauthRule, setDefaultDecision } from './ac
15
15
  import { setAgentEnvVar } from './env-admin.js';
16
16
  import { freeAgentName } from './interview-admin.js';
17
17
  import { writeSkill } from './skills-admin.js';
18
+ /** What a template's schedule runs (its workflow, when it comes from one,
19
+ * or its message), and whether it sends each result. */
20
+ function scheduleRuns(schedule) {
21
+ return { ...(schedule.workflowId ? { workflow_id: schedule.workflowId } : { message: schedule.message }), ...(schedule.notify ? { notify: true } : {}) };
22
+ }
18
23
  export class TemplateError extends Error {
19
24
  }
20
25
  /** A failed install, in a line the checklist can show: npm's own output
@@ -201,7 +206,7 @@ export async function createFromTemplateObject(template, deps) {
201
206
  const startAt = templateScheduleStart(setup.schedule);
202
207
  const task = await createScheduledTask({
203
208
  agent: name,
204
- message: setup.schedule.message,
209
+ ...scheduleRuns(setup.schedule),
205
210
  schedule: { every: setup.schedule.every, ...(startAt ? { start_at: startAt } : {}) },
206
211
  task_name: setup.schedule.name,
207
212
  });
@@ -246,7 +251,7 @@ async function createFromTemplateLegacy(id, deps) {
246
251
  if (setup.schedule) {
247
252
  try {
248
253
  const startAt = templateScheduleStart(setup.schedule);
249
- const task = await createScheduledTask({ agent: name, message: setup.schedule.message, schedule: { every: setup.schedule.every, ...(startAt ? { start_at: startAt } : {}) }, task_name: setup.schedule.name });
254
+ const task = await createScheduledTask({ agent: name, ...scheduleRuns(setup.schedule), schedule: { every: setup.schedule.every, ...(startAt ? { start_at: startAt } : {}) }, task_name: setup.schedule.name });
250
255
  await pauseScheduledTask(task.task_id);
251
256
  schedule = { description: describeTemplateSchedule(setup.schedule), timeZone: workspaceTimeZone(), ...(startAt ? { next_run_at: startAt } : {}) };
252
257
  }
@@ -2,7 +2,8 @@ import { type CreatedTemplateAgent, type TemplateChecklistItem, type TemplateCre
2
2
  import { type WorkflowPlan, type WorkflowPlannerTools } from '../core/system-agents/workflow-planner.js';
3
3
  import { type GeneratedMcpAbility, type McpAbilityRequest } from '../core/mcp-ability-generator.js';
4
4
  import type { McpDirectoryServer } from '../core/mcp-directory.js';
5
- import { type WorkflowRecord } from '../core/workflows.js';
5
+ import { type Workflow } from '../core/workflows.js';
6
+ import { type WorkflowRun } from '../core/workflow-runs.js';
6
7
  import type { ModelCall } from '../core/run-agent.js';
7
8
  export declare class WorkflowError extends Error {
8
9
  }
@@ -147,24 +148,48 @@ export declare function createWorkflow(input: {
147
148
  description?: unknown;
148
149
  mcpKeys?: unknown;
149
150
  }, deps: WorkflowCreateDeps): Promise<CreatedWorkflow>;
150
- export interface AgentWorkflowView extends WorkflowRecord {
151
- schedule?: {
152
- taskId: string;
153
- status: string;
154
- nextRunAt?: string;
155
- lastRunAt?: string;
156
- lastStatus?: string;
157
- waiting: boolean;
158
- };
151
+ type WorkflowScheduleView = {
152
+ taskId: string;
153
+ status: string;
154
+ repeating: boolean;
155
+ params?: Record<string, string>;
156
+ nextRunAt?: string;
157
+ lastRunAt?: string;
158
+ lastStatus?: string;
159
+ waiting: boolean;
160
+ };
161
+ export interface AgentWorkflowView extends Workflow {
162
+ /** Its main schedule: a repeating one if it has one. */
163
+ schedule?: WorkflowScheduleView;
164
+ /** Every live task that runs it, repeating ones first. */
165
+ schedules: WorkflowScheduleView[];
166
+ /** Its latest runs started by hand or by an app, newest first. */
167
+ recentRuns: WorkflowRun[];
159
168
  }
160
- /** An agent's workflows, each with its schedule's state when it has one. */
169
+ /** Moves an agent's workflows.yml, from before workflows were skills, into
170
+ * its skills, and points the schedules it linked by name at the workflows
171
+ * they run. Safe to call any time: it does nothing once the file is gone. */
172
+ export declare function migrateAgentWorkflows(agentName: string, agentDir: string): Promise<void>;
173
+ /** An agent's workflows, each with the state of the tasks that run it. */
161
174
  export declare function listAgentWorkflows(agentName: string, agentDir: string): Promise<AgentWorkflowView[]>;
162
- /** Starts a workflow now: its schedule runs once (a paused one goes back
163
- * to paused after), or, with no schedule, a one-off run. Questions and
164
- * approvals go to the agent's messaging app. */
165
- export declare function runWorkflowNow(agentName: string, agentDir: string, id: string): Promise<{
166
- taskId: string;
175
+ /** Gets a run of a workflow ready to start, with these parameters: checks
176
+ * them, and that a run with the same parameters isn't already under way
177
+ * (runs with other parameters go ahead), and works out the message it
178
+ * starts with — the workflow's start message and steps as they are now.
179
+ * The server starts it (core/workflow-runs.ts). */
180
+ export declare function prepareWorkflowRun(agentName: string, agentDir: string, id: string, rawParams?: unknown): Promise<{
181
+ workflow: Workflow;
182
+ params: Record<string, string>;
183
+ message: string;
184
+ }>;
185
+ /** Changes how a workflow's runs start, from the console's workflow
186
+ * editor: its title, start message, trigger and parameters. Its steps
187
+ * (the skill body) and the skill's other front matter are kept. */
188
+ export declare function updateWorkflow(agentDir: string, id: string, input: Record<string, unknown>): Workflow;
189
+ /** Forgets a workflow: its skill stays, as a plain skill, and the
190
+ * schedules that ran it are cancelled. Returns how many were. */
191
+ export declare function forgetWorkflow(agentName: string, agentDir: string, id: string): Promise<{
192
+ cancelled: number;
167
193
  }>;
168
- /** Forgets a workflow (its skill, schedule and rules stay). */
169
- export declare function forgetWorkflow(agentDir: string, id: string): void;
170
194
  export declare function agentDirIn(projectDir: string, name: string): string;
195
+ export {};
@@ -10,10 +10,11 @@ import { addActauthRule, readActauthConfig } from './actauth-admin.js';
10
10
  import { writeSkill } from './skills-admin.js';
11
11
  import { createFromTemplateObject, installFailureReason, templateChecklist } from './templates-admin.js';
12
12
  import { templateScheduleStart } from '../core/system-agents/templates.js';
13
- import { planWorkflowWithModel, slugify, workflowTemplate } from '../core/system-agents/workflow-planner.js';
13
+ import { planParameters, planWorkflowWithModel, slugify, workflowTemplate } from '../core/system-agents/workflow-planner.js';
14
14
  import { mcpAbilityNames } from '../core/mcp-ability-generator.js';
15
- import { readWorkflows, removeWorkflow, saveWorkflow } from '../core/workflows.js';
16
- import { createScheduledTask, listScheduledTasks, pauseScheduledTask, runScheduledTaskNow } from '../core/scheduler.js';
15
+ import { clearWorkflowFields, migrateWorkflowsFile, readWorkflow, readWorkflows, setWorkflowFields, validateParams, workflowRunMessage } from '../core/workflows.js';
16
+ import { isUnderWay, listRuns } from '../core/workflow-runs.js';
17
+ import { cancelScheduledTask, createScheduledTask, getSchedulerStore, listScheduledTasks, pauseScheduledTask } from '../core/scheduler.js';
17
18
  import { workspaceTimeZone } from '../core/time-zone.js';
18
19
  export class WorkflowError extends Error {
19
20
  }
@@ -304,18 +305,11 @@ export async function createWorkflow(input, deps) {
304
305
  if (!plan || typeof plan !== 'object' || !Array.isArray(plan.steps) || !plan.skill || !plan.newAgent)
305
306
  throw new WorkflowError('Plan the workflow first.');
306
307
  const target = (input.target ?? plan.target);
307
- const description = typeof input.description === 'string' ? input.description.trim().slice(0, 4000) : undefined;
308
- const record = (scheduleName) => ({
309
- id: plan.id,
310
- name: plan.name,
311
- summary: plan.summary,
312
- trigger: { kind: plan.trigger.kind, description: plan.trigger.description },
313
- steps: plan.steps,
314
- startMessage: plan.startMessage,
315
- ...(scheduleName ? { scheduleName } : {}),
316
- ...(description ? { description } : {}),
317
- createdAt: new Date().toISOString(),
318
- });
308
+ // The plan's skill becomes a workflow: how a run starts, in its front
309
+ // matter. Its parameters are tidied again, as the plan may have been
310
+ // edited on the review screen.
311
+ const { parameters } = planParameters(plan.parameters, { startMessage: plan.startMessage, skillBody: plan.skill.body, scheduled: plan.trigger.kind === 'schedule' });
312
+ const markWorkflow = (agentDir) => setWorkflowFields(agentDir, plan.id, { title: plan.name, startMessage: plan.startMessage, trigger: plan.trigger.description, parameters });
319
313
  const agents = await deps.agentInfo();
320
314
  resolveConnections(plan, agents);
321
315
  const keys = input.mcpKeys && typeof input.mcpKeys === 'object' ? Object.fromEntries(Object.entries(input.mcpKeys).flatMap(([k, v]) => (typeof v === 'string' && v.trim() ? [[k, v.trim()]] : []))) : {};
@@ -326,7 +320,7 @@ export async function createWorkflow(input, deps) {
326
320
  // Read on its next run: the agent needs no reloading for it.
327
321
  const connected = copyConnectedTools(plan, created.name, agents, deps.agentDir);
328
322
  const loadError = created.loadError;
329
- saveWorkflow(deps.agentDir(created.name), record(created.schedule ? plan.name : undefined));
323
+ markWorkflow(deps.agentDir(created.name));
330
324
  return {
331
325
  agent: created.name,
332
326
  newAgent: true,
@@ -426,7 +420,8 @@ export async function createWorkflow(input, deps) {
426
420
  const startAt = templateScheduleStart(template.setup.schedule);
427
421
  const task = await createScheduledTask({
428
422
  agent: agent.name,
429
- message: plan.startMessage,
423
+ workflow_id: plan.id,
424
+ ...(plan.notify?.on ? { notify: true } : {}),
430
425
  schedule: { every: template.setup.schedule.every, ...(startAt ? { start_at: startAt } : {}) },
431
426
  task_name: plan.name,
432
427
  });
@@ -437,7 +432,7 @@ export async function createWorkflow(input, deps) {
437
432
  scheduleError = err instanceof Error ? err.message : String(err);
438
433
  }
439
434
  }
440
- saveWorkflow(deps.agentDir(agent.name), record(schedule ? plan.name : undefined));
435
+ markWorkflow(deps.agentDir(agent.name));
441
436
  return {
442
437
  agent: agent.name,
443
438
  newAgent: false,
@@ -450,50 +445,122 @@ export async function createWorkflow(input, deps) {
450
445
  ...(loadError ? { loadError } : {}),
451
446
  };
452
447
  }
453
- /** An agent's workflows, each with its schedule's state when it has one. */
448
+ const RECENT_RUNS = 5;
449
+ /** The live tasks that run a workflow: those carrying its id. */
450
+ function workflowTasks(id, tasks) {
451
+ return tasks
452
+ .filter((t) => t.workflow_id === id && t.status !== 'cancelled' && t.status !== 'done')
453
+ .sort((a, b) => Number('every' in b.schedule) - Number('every' in a.schedule));
454
+ }
455
+ function scheduleView(task) {
456
+ return {
457
+ taskId: task.task_id,
458
+ status: task.status,
459
+ repeating: 'every' in task.schedule,
460
+ ...(task.params ? { params: task.params } : {}),
461
+ ...(task.next_run_at ? { nextRunAt: task.next_run_at } : {}),
462
+ ...(task.last_run_at ? { lastRunAt: task.last_run_at } : {}),
463
+ ...(task.last_status ? { lastStatus: task.last_status } : {}),
464
+ waiting: Boolean(task.pending) || task.status === 'awaiting_approval',
465
+ };
466
+ }
467
+ /** Moves an agent's workflows.yml, from before workflows were skills, into
468
+ * its skills, and points the schedules it linked by name at the workflows
469
+ * they run. Safe to call any time: it does nothing once the file is gone. */
470
+ export async function migrateAgentWorkflows(agentName, agentDir) {
471
+ const moved = migrateWorkflowsFile(agentDir);
472
+ if (!moved.length)
473
+ return;
474
+ for (const task of await listScheduledTasks({ agent: agentName })) {
475
+ const match = !task.workflow_id && task.status !== 'cancelled' && moved.find((m) => m.scheduleName && m.scheduleName === task.task_name);
476
+ if (!match)
477
+ continue;
478
+ await getSchedulerStore().update(task.task_id, (t) => t && { ...t, workflow_id: match.id, message: '' });
479
+ }
480
+ }
481
+ /** An agent's workflows, each with the state of the tasks that run it. */
454
482
  export async function listAgentWorkflows(agentName, agentDir) {
483
+ await migrateAgentWorkflows(agentName, agentDir);
455
484
  const workflows = readWorkflows(agentDir);
456
485
  if (!workflows.length)
457
486
  return [];
458
487
  const tasks = await listScheduledTasks({ agent: agentName });
488
+ const runs = await listRuns(agentName);
459
489
  return workflows.map((w) => {
460
- const task = w.scheduleName ? tasks.find((t) => t.task_name === w.scheduleName && t.status !== 'cancelled') : undefined;
461
- return task
462
- ? {
463
- ...w,
464
- schedule: {
465
- taskId: task.task_id,
466
- status: task.status,
467
- ...(task.next_run_at ? { nextRunAt: task.next_run_at } : {}),
468
- ...(task.last_run_at ? { lastRunAt: task.last_run_at } : {}),
469
- ...(task.last_status ? { lastStatus: task.last_status } : {}),
470
- waiting: Boolean(task.pending) || task.status === 'awaiting_approval',
471
- },
472
- }
473
- : w;
490
+ const schedules = workflowTasks(w.id, tasks).map(scheduleView);
491
+ const recentRuns = runs.filter((r) => r.workflow_id === w.id).slice(0, RECENT_RUNS);
492
+ return { ...w, schedules, recentRuns, ...(schedules[0] ? { schedule: schedules[0] } : {}) };
474
493
  });
475
494
  }
476
- /** Starts a workflow now: its schedule runs once (a paused one goes back
477
- * to paused after), or, with no schedule, a one-off run. Questions and
478
- * approvals go to the agent's messaging app. */
479
- export async function runWorkflowNow(agentName, agentDir, id) {
480
- const workflow = readWorkflows(agentDir).find((w) => w.id === id);
495
+ function sameParams(a, b) {
496
+ const left = a ?? {};
497
+ const keys = new Set([...Object.keys(left), ...Object.keys(b)]);
498
+ return [...keys].every((k) => (left[k] ?? '') === (b[k] ?? ''));
499
+ }
500
+ /** Gets a run of a workflow ready to start, with these parameters: checks
501
+ * them, and that a run with the same parameters isn't already under way
502
+ * (runs with other parameters go ahead), and works out the message it
503
+ * starts with — the workflow's start message and steps as they are now.
504
+ * The server starts it (core/workflow-runs.ts). */
505
+ export async function prepareWorkflowRun(agentName, agentDir, id, rawParams) {
506
+ const workflow = readWorkflow(agentDir, id);
481
507
  if (!workflow)
482
508
  throw new WorkflowError(`No workflow ${id} in ${agentName}.`);
483
- const views = await listAgentWorkflows(agentName, agentDir);
484
- const task = views.find((w) => w.id === id)?.schedule;
485
- if (task) {
486
- if (task.status === 'running' || task.waiting)
487
- throw new WorkflowError('It is already running, or waiting for a decision.');
488
- await runScheduledTaskNow(task.taskId);
489
- return { taskId: task.taskId };
509
+ let params;
510
+ try {
511
+ params = validateParams(workflow, rawParams);
512
+ }
513
+ catch (err) {
514
+ throw new WorkflowError(err instanceof Error ? err.message : String(err));
515
+ }
516
+ const runs = await listRuns(agentName, { workflowId: id });
517
+ if (runs.some((r) => isUnderWay(r) && sameParams(r.params, params))) {
518
+ throw new WorkflowError(`${workflow.title} is already running${Object.keys(params).length ? ' with these parameters' : ''}, or waiting for a decision.`);
490
519
  }
491
- const once = await createScheduledTask({ agent: agentName, message: workflow.startMessage, schedule: { run_at: new Date().toISOString() }, task_name: `${workflow.name} (run now)` });
492
- return { taskId: once.task_id };
520
+ return { workflow, params, message: workflowRunMessage(workflow, params) };
493
521
  }
494
- /** Forgets a workflow (its skill, schedule and rules stay). */
495
- export function forgetWorkflow(agentDir, id) {
496
- removeWorkflow(agentDir, id);
522
+ /** Changes how a workflow's runs start, from the console's workflow
523
+ * editor: its title, start message, trigger and parameters. Its steps
524
+ * (the skill body) and the skill's other front matter are kept. */
525
+ export function updateWorkflow(agentDir, id, input) {
526
+ if (!readWorkflow(agentDir, id))
527
+ throw new WorkflowError(`No workflow ${id}.`);
528
+ const text = (value, field, max) => {
529
+ if (value !== undefined && value !== null && typeof value !== 'string')
530
+ throw new WorkflowError(`${field} must be text.`);
531
+ const trimmed = (value ?? '').toString().trim();
532
+ if (trimmed.length > max)
533
+ throw new WorkflowError(`${field} is too long: keep it under ${max} characters.`);
534
+ return trimmed;
535
+ };
536
+ const title = text(input.title, 'The title', 120);
537
+ const startMessage = text(input.start_message, 'The start message', 4000);
538
+ if (!startMessage)
539
+ throw new WorkflowError('Write the start message: what each run begins with, e.g. "Check the refund request for order $order_number."');
540
+ const trigger = text(input.trigger, 'When it runs', 200);
541
+ const raw = input.parameters ?? {};
542
+ if (typeof raw !== 'object' || Array.isArray(raw))
543
+ throw new WorkflowError('parameters must be an object of names to { description, required }.');
544
+ const parameters = {};
545
+ for (const [name, spec] of Object.entries(raw)) {
546
+ if (!/^[a-z_][a-z0-9_]*$/.test(name))
547
+ throw new WorkflowError(`Parameter "${name}": use lowercase letters, digits and _, starting with a letter, e.g. "order_number".`);
548
+ const s = spec && typeof spec === 'object' ? spec : {};
549
+ parameters[name] = { description: text(s.description, `The description of "${name}"`, 300), required: s.required === true };
550
+ }
551
+ setWorkflowFields(agentDir, id, { ...(title ? { title } : {}), startMessage, ...(trigger ? { trigger } : {}), parameters });
552
+ return readWorkflow(agentDir, id);
553
+ }
554
+ /** Forgets a workflow: its skill stays, as a plain skill, and the
555
+ * schedules that ran it are cancelled. Returns how many were. */
556
+ export async function forgetWorkflow(agentName, agentDir, id) {
557
+ clearWorkflowFields(agentDir, id);
558
+ let cancelled = 0;
559
+ for (const task of workflowTasks(id, await listScheduledTasks({ agent: agentName }))) {
560
+ await cancelScheduledTask(task.task_id);
561
+ cancelled++;
562
+ }
563
+ return { cancelled };
497
564
  }
498
565
  export function agentDirIn(projectDir, name) {
499
566
  return join(projectDir, 'agents', name);
@@ -28,6 +28,12 @@ export const workflowPageHtml = `<!doctype html>
28
28
  .wf-num { width: 24px; color: var(--ink-muted); }
29
29
  .wf-target { display: grid; gap: 8px; }
30
30
  .wf-target label { display: flex; gap: 8px; align-items: center; font-size: 13.5px; }
31
+ .wf-notify { display: flex; gap: 8px; align-items: center; font-size: 13.5px; }
32
+ .wf-notify input { margin: 0; }
33
+ .wf-params { display: grid; gap: 6px; }
34
+ .wf-param { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; font-size: 13px; }
35
+ .wf-param .wf-param-name { width: 160px; font-family: var(--mono, monospace); }
36
+ .wf-param .wf-param-desc { flex: 1; min-width: 200px; }
31
37
  .wf-gaps { border: 1px solid var(--warn); background: var(--warn-soft); border-radius: var(--radius); padding: 10px 14px; }
32
38
  .wf-gaps ul, .wf-list { margin: 4px 0 0; padding-left: 18px; }
33
39
  .wf-gaps li, .wf-list li { margin: 3px 0; font-size: 13px; }
@@ -174,6 +180,54 @@ ${consoleShellScript}
174
180
  '</div>';
175
181
  }
176
182
 
183
+ // The $names a plan's start message and steps use: those parameters
184
+ // can't be removed here (the text needs them), only described.
185
+ function usedParamNames(p) {
186
+ var used = {};
187
+ var pattern = new RegExp('[$]([a-z_][a-z0-9_]*)', 'g');
188
+ var text = (p.startMessage || '') + ' ' + ((p.skill && p.skill.body) || '');
189
+ var m;
190
+ while ((m = pattern.exec(text))) used[m[1]] = true;
191
+ return used;
192
+ }
193
+
194
+ function paramRowHtml(name, spec, used) {
195
+ var fixed = name !== null;
196
+ return '<div class="wf-param"' + (fixed ? ' data-name="' + esc(name) + '"' : '') + '>' +
197
+ (fixed ? '<code class="wf-param-name">' + esc(name) + '</code>' : '<input class="wf-param-name" aria-label="Parameter name" placeholder="order_number">') +
198
+ '<input class="wf-param-desc" aria-label="What it is" placeholder="What it is" value="' + esc((spec && spec.description) || '') + '">' +
199
+ '<label><input type="checkbox" class="wf-param-required"' + (spec && spec.required ? ' checked' : '') + '> Required</label>' +
200
+ (fixed && used[name] ? '<span class="hint">used in the steps</span>' : '<button type="button" class="wf-btn plain wf-param-remove">Remove</button>') +
201
+ '</div>';
202
+ }
203
+
204
+ function paramsSectionHtml(p) {
205
+ var used = usedParamNames(p);
206
+ var names = Object.keys(p.parameters || {});
207
+ return '<h3>What each run is given</h3>' +
208
+ '<p class="hint" style="margin:0"><strong>Each run starts with:</strong> ' + esc(p.startMessage) + '</p>' +
209
+ '<p class="hint" style="margin:0">' + (names.length
210
+ ? 'Apps and people fill these in when they start a run; each $name in the start message and steps becomes its value.'
211
+ : 'Every run is the same. Add a parameter if something changes from run to run, such as an order number.') + '</p>' +
212
+ '<div class="wf-params" id="wfParams">' + names.map(function (n) { return paramRowHtml(n, p.parameters[n], used); }).join('') + '</div>' +
213
+ '<div><button type="button" class="wf-btn plain" id="wfAddParam">Add a parameter</button></div>';
214
+ }
215
+
216
+ // The parameters as edited on the plan; an error message for a bad name.
217
+ function readParams() {
218
+ var parameters = {};
219
+ var rows = page.querySelectorAll('#wfParams .wf-param');
220
+ for (var i = 0; i < rows.length; i++) {
221
+ var input = rows[i].querySelector('input.wf-param-name');
222
+ var name = rows[i].dataset.name || (input ? input.value.trim() : '');
223
+ if (!name) continue;
224
+ if (!/^[a-z_][a-z0-9_]*$/.test(name)) return { error: 'Parameter "' + name + '": use lowercase letters, digits and _, starting with a letter, e.g. order_number.' };
225
+ if (parameters[name]) return { error: 'Parameter "' + name + '" is listed twice.' };
226
+ parameters[name] = { description: rows[i].querySelector('.wf-param-desc').value.trim(), required: rows[i].querySelector('.wf-param-required').checked };
227
+ }
228
+ return { parameters: parameters };
229
+ }
230
+
177
231
  function renderPlan(error) {
178
232
  var p = state.planned.plan;
179
233
  var suggested = p.target.kind === 'existing' && p.target.agent ? p.target.agent : '';
@@ -182,6 +236,11 @@ ${consoleShellScript}
182
236
  '<div class="wf-card">' +
183
237
  '<h2>' + esc(p.name) + '</h2><p style="margin:0">' + esc(p.summary) + '</p>' +
184
238
  '<p class="hint" style="margin:0"><strong>Starts:</strong> ' + esc(p.trigger.description) + (p.trigger.kind === 'schedule' ? ' (created paused; you turn it on)' : '') + '</p>' +
239
+ (p.trigger.kind === 'schedule'
240
+ ? '<label class="wf-notify"><input type="checkbox" id="wfNotify"' + (p.notify && p.notify.on ? ' checked' : '') + '> Send each run' + String.fromCharCode(8217) + 's result to you through the agent' + String.fromCharCode(8217) + 's messaging app</label>' +
241
+ (p.notify && p.notify.reason ? '<p class="hint" style="margin:0 0 0 24px">' + esc(p.notify.reason) + '</p>' : '')
242
+ : '') +
243
+ paramsSectionHtml(p) +
185
244
  '<h3>Steps</h3>' +
186
245
  '<div class="le-table-wrap"><table class="wf-steps"><thead><tr><th></th><th>Step</th><th>Becomes</th><th>Checkpoint</th></tr></thead><tbody>' + p.steps.map(stepRow).join('') + '</tbody></table></div>' +
187
246
  (p.rules.length ? '<h3>Permissions it sets</h3><ul class="wf-list">' + p.rules.map(function (r) { return '<li><code>' + esc(r.tool) + '</code> ' + pill(r.decision) + '</li>'; }).join('') + '</ul>' : '') +
@@ -206,7 +265,20 @@ ${consoleShellScript}
206
265
  '<span class="hint" id="wfStatus"></span></div>' +
207
266
  (error ? '<p class="error">' + esc(error) + '</p>' : '');
208
267
  document.getElementById('wfBack').addEventListener('click', function () { renderDescribe(); });
268
+ var paramsBox = document.getElementById('wfParams');
269
+ paramsBox.addEventListener('click', function (ev) {
270
+ if (ev.target.classList.contains('wf-param-remove')) ev.target.closest('.wf-param').remove();
271
+ });
272
+ document.getElementById('wfAddParam').addEventListener('click', function () {
273
+ paramsBox.insertAdjacentHTML('beforeend', paramRowHtml(null, null, {}));
274
+ paramsBox.lastElementChild.querySelector('input.wf-param-name').focus();
275
+ });
209
276
  document.getElementById('wfCreate').addEventListener('click', function () {
277
+ var edited = readParams();
278
+ if (edited.error) { document.getElementById('wfStatus').textContent = edited.error; return; }
279
+ p.parameters = edited.parameters;
280
+ var notifyBox = document.getElementById('wfNotify');
281
+ if (notifyBox) p.notify = { on: notifyBox.checked, reason: (p.notify && p.notify.reason) || '' };
210
282
  var chosen = page.querySelector('input[name="wfTarget"]:checked');
211
283
  var target = chosen.value === 'new' ? { kind: 'new' } : { kind: 'existing', agent: chosen.value === 'other' ? document.getElementById('wfOther').value : chosen.dataset.agent };
212
284
  var btn = document.getElementById('wfCreate');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -124,6 +124,12 @@
124
124
  "dist/core/durable-approvals.d.ts",
125
125
  "dist/core/workflows.js",
126
126
  "dist/core/workflows.d.ts",
127
+ "dist/core/workflow-runs.js",
128
+ "dist/core/workflow-runs.d.ts",
129
+ "dist/core/run-callbacks.js",
130
+ "dist/core/run-callbacks.d.ts",
131
+ "dist/core/workflow-triggers.js",
132
+ "dist/core/workflow-triggers.d.ts",
127
133
  "dist/web",
128
134
  "protocol/loop-event.schema.json",
129
135
  "PROTOCOL.md",