@memberjunction/task-graph 6.1.0-edge.1 → 6.1.0-edge.2

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 (36) hide show
  1. package/README.md +185 -0
  2. package/dist/TaskClaimStore.d.ts +11 -0
  3. package/dist/TaskClaimStore.d.ts.map +1 -1
  4. package/dist/TaskClaimStore.js +8 -3
  5. package/dist/TaskClaimStore.js.map +1 -1
  6. package/dist/TaskGraphDispatcher.d.ts +364 -2
  7. package/dist/TaskGraphDispatcher.d.ts.map +1 -1
  8. package/dist/TaskGraphDispatcher.js +1534 -37
  9. package/dist/TaskGraphDispatcher.js.map +1 -1
  10. package/dist/TaskGraphService.d.ts +110 -1
  11. package/dist/TaskGraphService.d.ts.map +1 -1
  12. package/dist/TaskGraphService.js +458 -19
  13. package/dist/TaskGraphService.js.map +1 -1
  14. package/dist/TaskLoopExecutor.d.ts +62 -0
  15. package/dist/TaskLoopExecutor.d.ts.map +1 -0
  16. package/dist/TaskLoopExecutor.js +248 -0
  17. package/dist/TaskLoopExecutor.js.map +1 -0
  18. package/dist/WorkflowSpecSync.d.ts +28 -2
  19. package/dist/WorkflowSpecSync.d.ts.map +1 -1
  20. package/dist/WorkflowSpecSync.js +83 -2
  21. package/dist/WorkflowSpecSync.js.map +1 -1
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/operations/TaskGraphOperations.d.ts.map +1 -1
  27. package/dist/operations/TaskGraphOperations.js +4 -0
  28. package/dist/operations/TaskGraphOperations.js.map +1 -1
  29. package/dist/operations/WorkflowDraftOperation.d.ts +37 -0
  30. package/dist/operations/WorkflowDraftOperation.d.ts.map +1 -0
  31. package/dist/operations/WorkflowDraftOperation.js +141 -0
  32. package/dist/operations/WorkflowDraftOperation.js.map +1 -0
  33. package/dist/types.d.ts +114 -0
  34. package/dist/types.d.ts.map +1 -1
  35. package/dist/types.js.map +1 -1
  36. package/package.json +10 -7
@@ -15,8 +15,9 @@
15
15
  *
16
16
  * @module @memberjunction/task-graph
17
17
  */
18
- import { LogError, LogStatus, RunView, } from '@memberjunction/core';
19
- import { FormatValidationErrors, NormalizeDependency, ValidateTaskGraphSpec, } from '@memberjunction/ai-core-plus';
18
+ import { LogError, LogStatus, RunInEntityTransaction, RunView, } from '@memberjunction/core';
19
+ import { FormatValidationErrors, NormalizeDependency, RankGraphNodes, ValidateTaskGraphSpec, ConfigOf, } from '@memberjunction/ai-core-plus';
20
+ import { UUIDsEqual } from '@memberjunction/global';
20
21
  /**
21
22
  * Continuation chains are bounded separately from graph nesting.
22
23
  *
@@ -31,6 +32,7 @@ export const MAX_REINVOKE_DEPTH = 5;
31
32
  const DEFAULT_PARENT_METADATA = {
32
33
  continuation: 'message',
33
34
  reinvokeDepth: 0,
35
+ failureSemantics: 'block',
34
36
  submittedByAgentRunID: null,
35
37
  submittedByUserID: null,
36
38
  };
@@ -61,6 +63,7 @@ export function ParseTaskGraphParentMetadata(raw) {
61
63
  continuation: parsed.continuation === 'reinvoke' || parsed.continuation === 'none'
62
64
  ? parsed.continuation
63
65
  : 'message',
66
+ failureSemantics: parsed.failureSemantics === 'edges' ? 'edges' : 'block',
64
67
  reinvokeDepth: Number.isFinite(parsed.reinvokeDepth) ? Number(parsed.reinvokeDepth) : 0,
65
68
  };
66
69
  }
@@ -74,6 +77,181 @@ export function IsReinvokeCapReached(meta) {
74
77
  }
75
78
  /** Name of the task type used for agent-orchestrated graphs. */
76
79
  const TASK_TYPE_NAME = 'AI Workflow';
80
+ /**
81
+ * The node kinds a `Task` row can actually represent, and therefore the ones the dispatcher can run.
82
+ *
83
+ * `Agent` and `Action` have their own foreign keys; `Human` is a task with an assignee; `ForEach`
84
+ * and `While` carry their loop definition in `Task.Configuration` and their repeated body in the
85
+ * same `AgentID` / `ActionID` keys.
86
+ *
87
+ * `Prompt` joins them now that `TaskPromptRunner` exists — it carries `Task.PromptID`. `External`
88
+ * remains absent: it is completed by a system that has no way to report back, so persisting one
89
+ * would produce a task that waits forever.
90
+ */
91
+ const DISPATCHABLE_KINDS = [
92
+ 'Agent', 'Action', 'Human', 'ForEach', 'While', 'Prompt',
93
+ ];
94
+ /**
95
+ * Reports the node kinds this dispatcher cannot execute, or `null` when the graph is fully runnable.
96
+ *
97
+ * **Why this is a submit-time check and not a validation rule.** `ValidateTaskGraphSpec` is a pure
98
+ * function over the spec: it answers "is this a well-formed graph?", and its answer has to be the
99
+ * same in a browser, a CLI and a server. "Can it run *here*?" is a different question whose answer
100
+ * changes as runners are added, so it belongs to the runtime that owns the runners.
101
+ *
102
+ * **Why refuse rather than persist-and-stall.** Before this existed, `persistTasks` keyed off which
103
+ * configuration field happened to be populated and fell through to "Human task assigned to the
104
+ * submitter" for everything else — so a loop step silently became an approval request nobody asked
105
+ * for and nothing would ever complete. A graph that hangs forever while *looking* like it is waiting
106
+ * on a person is the most expensive failure available here. Refusing at the door instead names the
107
+ * offending step while the workflow is still the author's to edit.
108
+ */
109
+ /**
110
+ * Projects a spec node onto the `Task.Configuration` bag.
111
+ *
112
+ * Everything the row cannot hold in a column of its own: the kind-specific settings, the payload
113
+ * mappings, and the execution policy. Returning `null` for an empty result keeps `Configuration`
114
+ * NULL rather than `"{}"`, so "this step has no settings" reads the same in the database as it does
115
+ * in the spec.
116
+ *
117
+ * **The mappings are the point.** They are how a step's result reaches the payload, and every
118
+ * branch condition downstream reads the payload — so a step persisted without them produces a
119
+ * workflow whose conditions all evaluate against nothing. Undefined is falsy, so that failure looks
120
+ * exactly like a branch legitimately not being taken.
121
+ */
122
+ export function BuildStepConfiguration(node) {
123
+ const config = {};
124
+ const agent = ConfigOf(node, 'Agent');
125
+ if (agent?.message || agent?.templateParameters) {
126
+ config.agent = { message: agent.message, templateParameters: agent.templateParameters };
127
+ }
128
+ const prompt = ConfigOf(node, 'Prompt');
129
+ if (prompt?.templateParameters)
130
+ config.prompt = { templateParameters: prompt.templateParameters };
131
+ const forEach = ConfigOf(node, 'ForEach');
132
+ if (forEach)
133
+ config.forEach = forEach;
134
+ const whileOp = ConfigOf(node, 'While');
135
+ if (whileOp)
136
+ config.while = whileOp;
137
+ // `expiresInHours` as well as instructions: dropping it here is what would leave the deadline in
138
+ // the spec and out of the row the dispatcher actually reads, so the request would be raised with
139
+ // no ExpiresAt and the author's timeout would silently not exist.
140
+ const human = ConfigOf(node, 'Human');
141
+ if (human?.instructions || human?.expiresInHours) {
142
+ config.human = { instructions: human.instructions, expiresInHours: human.expiresInHours };
143
+ }
144
+ const external = ConfigOf(node, 'External');
145
+ if (external)
146
+ config.external = external;
147
+ // Mappings live on the Action arm of the spec, but they are not action-specific: a loop step
148
+ // carries them too, which is how its per-iteration inputs and results are wired.
149
+ const action = ConfigOf(node, 'Action');
150
+ const inputMapping = action?.inputMapping;
151
+ const outputMapping = action?.outputMapping;
152
+ if (inputMapping)
153
+ config.inputMapping = inputMapping;
154
+ if (outputMapping)
155
+ config.outputMapping = outputMapping;
156
+ if (node.policy) {
157
+ config.policy = {
158
+ timeoutSeconds: node.policy.timeoutSeconds,
159
+ retryCount: node.policy.retryCount,
160
+ onError: node.policy.onError,
161
+ };
162
+ }
163
+ // The author's own arrangement, carried through so a hand-drawn workflow runs — and appears in
164
+ // run history — in the shape they drew. Dropping it (as this did) meant a workflow someone had
165
+ // laid out carefully came back as a machine-arranged graph the first time they watched it run.
166
+ // Only authored geometry is stored: a graph with none has its layout derived at render time, and
167
+ // persisting a derived layout would freeze one rendering of a graph that can still change.
168
+ if (node.layout && Object.keys(node.layout).length > 0) {
169
+ config.layout = {
170
+ x: node.layout.x,
171
+ y: node.layout.y,
172
+ width: node.layout.width,
173
+ height: node.layout.height,
174
+ };
175
+ }
176
+ return Object.keys(config).length > 0 ? config : null;
177
+ }
178
+ /** Internal alias so the persistence path reads as a step, not as a projection. */
179
+ const buildStepConfiguration = BuildStepConfiguration;
180
+ /**
181
+ * The loop definition on a node, whichever loop kind it is.
182
+ *
183
+ * ForEach and While differ in how they decide to iterate, not in what they repeat, so everything
184
+ * downstream of that decision — body resolution, name collection, persistence — treats them alike.
185
+ */
186
+ export function LoopOperationOf(node) {
187
+ return ConfigOf(node, 'ForEach') ?? ConfigOf(node, 'While') ?? null;
188
+ }
189
+ /** Every agent name a node references, including the sub-agent a loop repeats. */
190
+ function agentNamesIn(node) {
191
+ const names = [ConfigOf(node, 'Agent')?.agentName, LoopOperationOf(node)?.subAgent?.name];
192
+ return names.filter((n) => !!n);
193
+ }
194
+ /** Every prompt name a node references, including the prompt a loop repeats. */
195
+ function promptNamesIn(node) {
196
+ const names = [ConfigOf(node, 'Prompt')?.promptName, LoopOperationOf(node)?.prompt?.name];
197
+ return names.filter((n) => !!n);
198
+ }
199
+ /** Every action name a node references, including the action a loop repeats. */
200
+ function actionNamesIn(node) {
201
+ const names = [ConfigOf(node, 'Action')?.actionName, LoopOperationOf(node)?.action?.name];
202
+ return names.filter((n) => !!n);
203
+ }
204
+ /**
205
+ * The transaction capability of a provider, when it has one.
206
+ *
207
+ * `IMetadataProvider` does not declare transaction support — a browser provider genuinely has none —
208
+ * so this narrows by CAPABILITY rather than asserting a type the interface does not promise. A
209
+ * provider without it returns undefined and `RunInEntityTransaction` runs the work directly, which
210
+ * is the honest degradation: server submissions get atomicity, and a client submission behaves
211
+ * exactly as it did before rather than failing at a call site that claimed something untrue.
212
+ */
213
+ function asTransactionCapable(provider) {
214
+ const candidate = provider;
215
+ return candidate.SupportsEntityTransactions === true && typeof candidate.BeginEntityTransaction === 'function'
216
+ ? candidate
217
+ : undefined;
218
+ }
219
+ export function FindUnrunnableKinds(spec) {
220
+ const offenders = spec.tasks.filter((t) => !DISPATCHABLE_KINDS.includes(t.kind));
221
+ if (offenders.length === 0)
222
+ return null;
223
+ const detail = offenders.map((t) => `"${t.name}" (${t.kind})`).join(', ');
224
+ return (`"${spec.workflowName}" cannot be run yet: ${detail}. ` +
225
+ `The dispatcher runs agent, action, person and loop steps. Prompt steps and steps completed ` +
226
+ `by an outside system are not supported yet — replace them, or split them out of this workflow.`);
227
+ }
228
+ /**
229
+ * Reports human steps assigned to someone other than the submitter, or `null` when there are none.
230
+ *
231
+ * Cross-user assignment needs an authorization model (#3524) — deciding that A may put work in B's
232
+ * inbox is a permissions question, not a graph question. Until it lands, a workflow can only ask the
233
+ * person who started it.
234
+ *
235
+ * **Why refuse rather than reassign.** Persist wrote `task.UserID = submitter` unconditionally, so
236
+ * an authored `assignToUserID` was overwritten in silence. Every layer above accepts the field —
237
+ * the flow compiler reads it into the spec, the validator passes it, the spec type declares it — so
238
+ * silence here is indistinguishable from support: the graph submits, a step appears in the WRONG
239
+ * person's inbox, the named person is never told, and the author has no reason to suspect any of it.
240
+ * Refusing while the graph is still the author's to edit is the only point at which saying so costs
241
+ * nothing.
242
+ */
243
+ export function FindCrossUserAssignments(spec, submitterUserID) {
244
+ const offenders = spec.tasks.filter((t) => {
245
+ const assignTo = ConfigOf(t, 'Human')?.assignToUserID;
246
+ return !!assignTo && !UUIDsEqual(assignTo, submitterUserID);
247
+ });
248
+ if (offenders.length === 0)
249
+ return null;
250
+ const detail = offenders.map((t) => `"${t.name}"`).join(', ');
251
+ return (`"${spec.workflowName}" was not started: ${detail} asks a person other than whoever runs the ` +
252
+ `workflow. Assigning a step to someone else is not available yet (#3524) — a workflow can ` +
253
+ `only ask the person who started it. Remove assignToUserID from those steps.`);
254
+ }
77
255
  export class TaskGraphService {
78
256
  /**
79
257
  * Validates and persists a task graph, returning as soon as it is durable.
@@ -92,20 +270,87 @@ export class TaskGraphService {
92
270
  LogError(`[TaskGraphService] ${message}`);
93
271
  return { Success: false, ErrorMessage: message };
94
272
  }
273
+ // 1b. Representability. Validation asks "is this a well-formed graph?"; this asks "can THIS
274
+ // dispatcher run it?" — a different question, and one the pure validator has no business
275
+ // answering, since capability is a property of the runtime, not of the spec.
276
+ const unrunnable = this.findUnrunnableKinds(spec);
277
+ if (unrunnable) {
278
+ LogError(`[TaskGraphService] ${unrunnable}`);
279
+ return { Success: false, ErrorMessage: unrunnable };
280
+ }
281
+ // 1b-ii. Assignability. Same question, narrower: this dispatcher can only ask the person who
282
+ // submitted the graph. Persist used to overwrite an authored `assignToUserID` with
283
+ // the submitter and say nothing, so a step meant for someone else landed in the
284
+ // wrong inbox and the named person was never told. The compiler accepts the field and
285
+ // the validator passes it, which makes silence here indistinguishable from support.
286
+ const misassigned = FindCrossUserAssignments(spec, context.ContextUser.ID);
287
+ if (misassigned) {
288
+ LogError(`[TaskGraphService] ${misassigned}`);
289
+ return { Success: false, ErrorMessage: misassigned };
290
+ }
291
+ // 1c. Chain depth. A flow that dispatches a graph containing itself recurses without bound,
292
+ // and each hop costs real money and real rows before anyone notices. The cap is checked
293
+ // HERE rather than at execution because refusing to write the graph is the only point at
294
+ // which nothing has happened yet.
295
+ const depth = context.ReinvokeDepth ?? 0;
296
+ if (depth >= MAX_REINVOKE_DEPTH) {
297
+ const message = `"${spec.workflowName}" was not started: it is ${depth} levels deep in a chain of ` +
298
+ `workflows starting workflows, which is the limit. A workflow that reaches this is ` +
299
+ `almost always calling itself, directly or through another one.`;
300
+ LogError(`[TaskGraphService] ${message}`);
301
+ return { Success: false, ErrorMessage: message };
302
+ }
95
303
  try {
96
- // 2. Resolve every agent BEFORE writing anything. An unresolvable agent is a hard
97
- // error, not a skipped node: silently dropping a task executes the graph with holes
98
- // where the caller's work should have been.
304
+ // 2. Resolve every agent and action BEFORE writing anything. An unresolvable name is a
305
+ // hard error, not a skipped node: silently dropping a task executes the graph with
306
+ // holes where the caller's work should have been.
99
307
  const agentIDsByName = await this.resolveAgents(spec, context);
100
308
  if (!agentIDsByName.Success) {
101
309
  return { Success: false, ErrorMessage: agentIDsByName.ErrorMessage };
102
310
  }
311
+ const actionIDsByName = await this.resolveActions(spec, context);
312
+ if (!actionIDsByName.Success) {
313
+ return { Success: false, ErrorMessage: actionIDsByName.ErrorMessage };
314
+ }
315
+ const promptIDsByName = await this.resolvePrompts(spec, context);
316
+ if (!promptIDsByName.Success) {
317
+ return { Success: false, ErrorMessage: promptIDsByName.ErrorMessage };
318
+ }
103
319
  const taskTypeID = await this.ensureTaskType(context);
104
- // 3. Persist. Parent first so children have a ParentID, then children, then edges —
105
- // edges last because they reference two child IDs that must both exist.
106
- const parentTaskID = await this.persistParent(spec, taskTypeID, context);
107
- const taskIDMap = await this.persistChildren(spec, parentTaskID, taskTypeID, agentIDsByName.Map, context);
108
- await this.persistDependencies(spec, taskIDMap, context);
320
+ // 3. Persist — ALL of it, or none of it.
321
+ //
322
+ // ── The whole graph becomes visible together, or not at all ─────────────────────────
323
+ // The dispatcher discovers work by polling for child tasks in 'Pending'. Writing the
324
+ // children first and their dependencies afterwards leaves a window — milliseconds, but
325
+ // real — in which every task exists with NO prerequisites recorded yet. A poll landing
326
+ // there sees a graph of independent tasks and claims all of them at once.
327
+ //
328
+ // Observed, not theorised: in graph C08B36E3 the dependency gating `Research: focused`
329
+ // was written at 00:00:59.653 and that task STARTED at 00:00:59.647 — six milliseconds
330
+ // before the edge that was supposed to hold it back existed. `Close out: approved` ran
331
+ // in the same wave as the research steps, before the draft it was meant to judge had
332
+ // been written. The graph then reported Complete, having executed in an order its author
333
+ // never drew.
334
+ //
335
+ // A transaction is the whole fix: the dispatcher cannot observe a half-built graph
336
+ // because a half-built graph is never visible. `RunInEntityTransaction` degrades to
337
+ // running the work as-is on a provider that cannot transact, which is the correct
338
+ // fallback rather than a silent failure.
339
+ //
340
+ // The PARENT is inside it too. Written outside, a failure while persisting children or
341
+ // edges would roll those back and leave a childless parent durably in 'Pending' — which
342
+ // never settles (the rollup deliberately skips a graph with no nodes) and never dies, so
343
+ // the active-graph scan picks it up on every poll forever: permanent debris plus a
344
+ // permanent tick of wasted work for each failed submit. Ordering inside the transaction
345
+ // is unchanged, because edges only ever reference child IDs.
346
+ const { parentTaskID, taskIDMap } = await RunInEntityTransaction(asTransactionCapable(context.Provider), async () => {
347
+ // Parent first so children have a ParentID, then children, then edges — edges
348
+ // last because they reference two child IDs that must both exist.
349
+ const parentID = await this.persistParent(spec, taskTypeID, context);
350
+ const map = await this.persistChildren(spec, parentID, taskTypeID, agentIDsByName.Map, actionIDsByName.Map, promptIDsByName.Map, context);
351
+ await this.persistDependencies(spec, map, context);
352
+ return { parentTaskID: parentID, taskIDMap: map };
353
+ });
109
354
  LogStatus(`[TaskGraphService] Submitted "${spec.workflowName}": parent ${parentTaskID}, ${taskIDMap.size} task(s). ` +
110
355
  `Awaiting dispatcher pickup.`);
111
356
  return { Success: true, ParentTaskID: parentTaskID, TaskIDMap: taskIDMap };
@@ -135,6 +380,13 @@ export class TaskGraphService {
135
380
  LogError(`[TaskGraphService] Failed to cancel task ${child.ID}: ${child.LatestResult?.CompleteMessage ?? 'unknown error'}`);
136
381
  }
137
382
  }
383
+ // Withdraw the questions too. A human step that was waiting has an open
384
+ // `MJ: AI Agent Requests` row, and cancelling only the Task left that row `Requested`
385
+ // FOREVER: the person keeps seeing "a workflow is waiting on you" in their inbox for a
386
+ // workflow that no longer exists, and answering it settles nothing because the task is
387
+ // already Cancelled. Nothing else ever closes these — the dispatcher only expires rows
388
+ // that carry a deadline, and most do not.
389
+ await this.cancelOpenRequests(children.map((c) => c.ID), context);
138
390
  const parent = await context.Provider.GetEntityObject('MJ: Tasks', context.ContextUser);
139
391
  if (!(await parent.Load(parentTaskID)))
140
392
  return false;
@@ -147,6 +399,50 @@ export class TaskGraphService {
147
399
  return false;
148
400
  }
149
401
  }
402
+ /**
403
+ * Closes the still-open requests raised for a set of tasks.
404
+ *
405
+ * `Canceled` rather than `Expired`: nobody ran out of time, the ask was withdrawn — and the two
406
+ * mean different things downstream, since the dispatcher treats an expired human step as a
407
+ * FAILURE a give-up edge can route around, which would be a lie about a graph somebody stopped
408
+ * on purpose.
409
+ *
410
+ * Failures here are logged and never propagated: the graph is already cancelled, and refusing to
411
+ * finish that because an inbox row would not close would leave the graph in a worse state than
412
+ * the debris it is trying to avoid.
413
+ */
414
+ async cancelOpenRequests(taskIDs, context) {
415
+ if (taskIDs.length === 0)
416
+ return;
417
+ try {
418
+ const idList = taskIDs.map((id) => `'${id}'`).join(',');
419
+ const open = await RunView.FromMetadataProvider(context.Provider).RunView({
420
+ EntityName: 'MJ: AI Agent Requests',
421
+ ExtraFilter: `Status='Requested' AND OriginatingTaskID IN (${idList})`,
422
+ ResultType: 'entity_object',
423
+ BypassCache: true,
424
+ }, context.ContextUser);
425
+ if (!open.Success) {
426
+ LogError(`[TaskGraphService] Could not read open requests to cancel: ${open.ErrorMessage}`);
427
+ return;
428
+ }
429
+ for (const request of open.Results ?? []) {
430
+ request.Status = 'Canceled';
431
+ request.Comments = 'The workflow that asked this was cancelled.';
432
+ if (!(await request.Save())) {
433
+ LogError(`[TaskGraphService] Could not withdraw request ${request.ID}: ` +
434
+ `${request.LatestResult?.CompleteMessage ?? 'unknown error'}. It will keep showing ` +
435
+ `in someone's inbox for a workflow that no longer exists.`);
436
+ }
437
+ }
438
+ if ((open.Results ?? []).length > 0) {
439
+ LogStatus(`[TaskGraphService] Withdrew ${open.Results.length} open request(s) for the cancelled graph.`);
440
+ }
441
+ }
442
+ catch (e) {
443
+ LogError(`[TaskGraphService] Could not withdraw open requests: ${e instanceof Error ? e.message : String(e)}`);
444
+ }
445
+ }
150
446
  /**
151
447
  * Returns a failed task to `Pending` so the dispatcher can run it again.
152
448
  *
@@ -193,7 +489,10 @@ export class TaskGraphService {
193
489
  // ────────────────────────────────────────────────────────────────────────
194
490
  /** Maps every referenced agent name to its ID, or reports all unresolvable names at once. */
195
491
  async resolveAgents(spec, context) {
196
- const names = [...new Set(spec.tasks.filter((t) => !!t.agentName).map((t) => t.agentName))];
492
+ // A loop's repeated sub-agent counts as a referenced agent. Collecting only the Agent nodes
493
+ // left a loop body pointing at a name nothing had resolved, so the row got no AgentID and
494
+ // the loop had nothing to run.
495
+ const names = [...new Set(spec.tasks.flatMap(agentNamesIn))];
197
496
  if (names.length === 0)
198
497
  return { Success: true, Map: new Map() };
199
498
  const quoted = names.map((n) => `'${n.replace(/'/g, "''")}'`).join(',');
@@ -209,6 +508,54 @@ export class TaskGraphService {
209
508
  }
210
509
  return { Success: true, Map: found };
211
510
  }
511
+ /**
512
+ * Maps every referenced action name to its ID, or reports all unresolvable names at once.
513
+ *
514
+ * Deliberately a mirror of {@link resolveAgents} rather than a generalization of it: the two
515
+ * read different entities and produce different error prose, and the shared shape is three
516
+ * lines. Collapsing them would trade a readable failure message for a parameterized lookup.
517
+ */
518
+ /**
519
+ * Maps every referenced prompt name to its ID.
520
+ *
521
+ * A prompt is addressed by name in the spec and stored as a foreign key on the row, exactly like
522
+ * agents and actions — a name in JSON cannot be joined, checked, or survive a rename.
523
+ */
524
+ async resolvePrompts(spec, context) {
525
+ const names = [...new Set(spec.tasks.flatMap(promptNamesIn))];
526
+ if (names.length === 0)
527
+ return { Success: true, Map: new Map() };
528
+ const quoted = names.map((n) => `'${n.replace(/'/g, "''")}'`).join(',');
529
+ const result = await RunView.FromMetadataProvider(context.Provider).RunView({ EntityName: 'MJ: AI Prompts', ExtraFilter: `Name IN (${quoted})`, Fields: ['ID', 'Name'], ResultType: 'simple' }, context.ContextUser);
530
+ const found = new Map((result.Results ?? []).map((r) => [r.Name, r.ID]));
531
+ const missing = names.filter((n) => !found.has(n));
532
+ if (missing.length > 0) {
533
+ return {
534
+ Success: false,
535
+ ErrorMessage: `Task graph "${spec.workflowName}" references ${missing.length} unknown prompt(s): ${missing.join(', ')}. ` +
536
+ `Submitting would execute the graph with holes where those steps should be.`,
537
+ };
538
+ }
539
+ return { Success: true, Map: found };
540
+ }
541
+ async resolveActions(spec, context) {
542
+ // Includes a loop's repeated action — see the note in resolveAgents.
543
+ const names = [...new Set(spec.tasks.flatMap(actionNamesIn))];
544
+ if (names.length === 0)
545
+ return { Success: true, Map: new Map() };
546
+ const quoted = names.map((n) => `'${n.replace(/'/g, "''")}'`).join(',');
547
+ const result = await RunView.FromMetadataProvider(context.Provider).RunView({ EntityName: 'MJ: Actions', ExtraFilter: `Name IN (${quoted})`, Fields: ['ID', 'Name'], ResultType: 'simple' }, context.ContextUser);
548
+ const found = new Map((result.Results ?? []).map((r) => [r.Name, r.ID]));
549
+ const missing = names.filter((n) => !found.has(n));
550
+ if (missing.length > 0) {
551
+ return {
552
+ Success: false,
553
+ ErrorMessage: `Task graph "${spec.workflowName}" references ${missing.length} unknown action(s): ${missing.join(', ')}. ` +
554
+ `Submitting would execute the graph with holes where those tasks should be.`,
555
+ };
556
+ }
557
+ return { Success: true, Map: found };
558
+ }
212
559
  /** Finds or creates the task type used for orchestrated graphs. */
213
560
  async ensureTaskType(context) {
214
561
  const existing = await RunView.FromMetadataProvider(context.Provider).RunView({ EntityName: 'MJ: Task Types', ExtraFilter: `Name='${TASK_TYPE_NAME}'`, Fields: ['ID'], ResultType: 'simple', MaxRows: 1 }, context.ContextUser);
@@ -235,6 +582,11 @@ export class TaskGraphService {
235
582
  parent.ConversationDetailID = context.ConversationDetailID ?? null;
236
583
  parent.Status = 'In Progress';
237
584
  parent.PercentComplete = 0;
585
+ // The run that submitted this graph, in the COLUMN and not only in the metadata JSON below.
586
+ // A json field cannot be joined, so provenance that lives only there is unavailable to any
587
+ // query — which is how a human step ended up with no agent to ask on behalf of, and how a
588
+ // dispatched run had no parent to roll its cost up to.
589
+ parent.AgentRunID = context.AgentRunID ?? null;
238
590
  // The parent row carries what happens AFTER the graph settles. It lives here rather than in
239
591
  // dispatcher memory because the dispatcher that finishes a graph is frequently not the
240
592
  // process that accepted it — a restart, a second instance, or simply a long-running graph
@@ -242,6 +594,7 @@ export class TaskGraphService {
242
594
  parent.InputPayload = JSON.stringify({
243
595
  continuation: spec.continuation ?? 'message',
244
596
  reinvokeDepth: context.ReinvokeDepth ?? 0,
597
+ failureSemantics: spec.failureSemantics ?? 'block',
245
598
  submittedByAgentRunID: context.AgentRunID ?? null,
246
599
  submittedByUserID: context.ContextUser?.ID ?? null,
247
600
  });
@@ -251,8 +604,11 @@ export class TaskGraphService {
251
604
  return parent.ID;
252
605
  }
253
606
  /** Writes each child task, returning the tempId -> real ID mapping edges will need. */
254
- async persistChildren(spec, parentTaskID, taskTypeID, agentIDsByName, context) {
607
+ async persistChildren(spec, parentTaskID, taskTypeID, agentIDsByName, actionIDsByName, promptIDsByName, context) {
255
608
  const map = new Map();
609
+ // Resolved ONCE over the whole graph: a rank is a node's position in the topology, so it
610
+ // cannot be computed per node without seeing all of them.
611
+ const ranks = RankGraphNodes(spec.tasks.map((t) => t.tempId), spec.tasks.flatMap((t) => (t.dependsOn ?? []).map((d) => ({ From: NormalizeDependency(d).tempId, To: t.tempId }))));
256
612
  for (const node of spec.tasks) {
257
613
  const task = await context.Provider.GetEntityObject('MJ: Tasks', context.ContextUser);
258
614
  task.NewRecord();
@@ -264,14 +620,69 @@ export class TaskGraphService {
264
620
  task.ConversationDetailID = context.ConversationDetailID ?? null;
265
621
  task.Status = 'Pending';
266
622
  task.PercentComplete = 0;
267
- if (node.agentName) {
268
- task.AgentID = agentIDsByName.get(node.agentName);
269
- }
270
- else {
271
- // Human task. Assigned to the submitting user only — cross-user assignment stays
272
- // rejected until the authorization model in #3524 lands.
273
- task.UserID = context.ContextUser.ID;
623
+ // The discriminator the dispatcher routes on. Written first because everything below —
624
+ // and everything the dispatcher later does with this row — reads it.
625
+ task.StepType = node.kind;
626
+ // Switching on `kind` rather than on which config field happens to be populated. The
627
+ // old shape — "agentName? then agent; actionName? then action; ELSE human" — turned
628
+ // every kind it did not know about into a Human task assigned to the submitter, so a
629
+ // ForEach step became an approval request that nobody had asked for and nothing would
630
+ // ever complete. A graph that stalls forever while LOOKING like it is waiting on a
631
+ // person is the worst available failure. `findUnrunnableKinds` refuses unsupported
632
+ // kinds before anything is written; this switch is what keeps the two in step.
633
+ switch (node.kind) {
634
+ case 'Agent':
635
+ task.AgentID = agentIDsByName.get(ConfigOf(node, 'Agent').agentName);
636
+ break;
637
+ case 'Action':
638
+ task.ActionID = actionIDsByName.get(ConfigOf(node, 'Action').actionName);
639
+ break;
640
+ case 'Human':
641
+ // Assigned to the submitting user only — cross-user assignment stays rejected
642
+ // until the authorization model in #3524 lands, and `findCrossUserAssignments`
643
+ // has already refused any graph that asked for someone else.
644
+ task.UserID = context.ContextUser.ID;
645
+ break;
646
+ case 'Prompt':
647
+ task.PromptID = promptIDsByName.get(ConfigOf(node, 'Prompt').promptName);
648
+ break;
649
+ case 'ForEach':
650
+ case 'While': {
651
+ // A loop's key points at what it REPEATS, not at the loop itself. That keeps the
652
+ // reference a real foreign key — joinable, constrained, rename-proof — instead
653
+ // of a name buried in JSON, and CK_Task_Assignment still holds because a loop
654
+ // body is exactly one thing.
655
+ const op = LoopOperationOf(node);
656
+ if (op?.action)
657
+ task.ActionID = actionIDsByName.get(op.action.name);
658
+ else if (op?.subAgent)
659
+ task.AgentID = agentIDsByName.get(op.subAgent.name);
660
+ else if (op?.prompt)
661
+ task.PromptID = promptIDsByName.get(op.prompt.name);
662
+ else {
663
+ throw new Error(`Task "${node.name}" is a loop with nothing to repeat. Choose an action, ` +
664
+ `a prompt, or a sub-agent for it to run on each pass.`);
665
+ }
666
+ break;
667
+ }
668
+ default:
669
+ // Unreachable: findUnrunnableKinds rejected these before any write. Throwing
670
+ // rather than falling through means a kind added later fails loudly here
671
+ // instead of quietly becoming somebody's to-do item.
672
+ throw new Error(`Task "${node.name}" has kind "${node.kind}", which cannot be persisted for dispatch.`);
274
673
  }
674
+ // Everything about the step that has no column of its own. Dropping this is what used
675
+ // to lose the input/output mappings — and with them the payload values every branch
676
+ // condition downstream reads.
677
+ //
678
+ // The step's rank in the graph's own order rides along. `Task` has no sequence column,
679
+ // so without it every consumer listing a graph's steps falls back to creation order —
680
+ // the compiler's walk, which is neither the order they were drawn in nor the order they
681
+ // run in. A graph that has not started yet has no timestamps to sort by, so its
682
+ // structure is the only order available, and it is available from the moment it is
683
+ // compiled.
684
+ const configuration = { ...buildStepConfiguration(node), sequence: ranks.get(node.tempId) };
685
+ task.Configuration = JSON.stringify(configuration);
275
686
  // Input rides in its own column; Description stays human-readable.
276
687
  task.InputPayload = node.inputPayload ? JSON.stringify(node.inputPayload) : null;
277
688
  if (!(await task.Save())) {
@@ -281,6 +692,25 @@ export class TaskGraphService {
281
692
  }
282
693
  return map;
283
694
  }
695
+ /**
696
+ * Reports the node kinds this dispatcher cannot yet execute, or `null` when the graph is
697
+ * entirely runnable.
698
+ *
699
+ * **Why this is a submit-time check and not a validation rule.** `ValidateTaskGraphSpec` is a
700
+ * pure function over the spec: it answers "is this a well-formed graph?", and its answer must be
701
+ * the same in a browser, a CLI and a server. "Can it run *here*?" is a different question whose
702
+ * answer changes as runners are added, so it belongs to the runtime that owns the runners.
703
+ *
704
+ * **Why refuse rather than persist-and-stall.** `Prompt`, `ForEach`, `While` and `External`
705
+ * are legitimate parts of the spec, but the `Task` row has nowhere to carry a node's `kind` or
706
+ * its typed `configuration` — so a persisted one could not be dispatched even if a runner
707
+ * existed. Refusing at the door tells the author which step is the problem while the graph is
708
+ * still theirs to edit. The alternative is a graph that submits successfully and then never
709
+ * finishes, which costs an operator an afternoon to diagnose.
710
+ */
711
+ findUnrunnableKinds(spec) {
712
+ return FindUnrunnableKinds(spec);
713
+ }
284
714
  /** Writes the dependency edges, translating tempIds to persisted IDs. */
285
715
  async persistDependencies(spec, taskIDMap, context) {
286
716
  for (const node of spec.tasks) {
@@ -300,6 +730,15 @@ export class TaskGraphService {
300
730
  // NULL for an unconditional edge, matching AIAgentStepPath — so a graph authored in
301
731
  // the flow editor and one emitted by an agent store the same thing.
302
732
  dep.Condition = edge.condition ?? null;
733
+ // The exclusive-choice fields. Dropping these was silent and severe: without an
734
+ // ExclusiveGroup the dispatcher sees a plain fan-out and runs EVERY branch, so a
735
+ // workflow that should pick one route would take all of them — doing work its author
736
+ // never intended and, in the Demo workflow's case, calling two different APIs where
737
+ // the flow calls one. Priority and Sequence are what decide which branch wins, so a
738
+ // group without them resolves arbitrarily.
739
+ dep.ExclusiveGroup = edge.exclusiveGroup ?? null;
740
+ dep.Priority = edge.priority ?? 0;
741
+ dep.Sequence = edge.sequence ?? 0;
303
742
  if (!(await dep.Save())) {
304
743
  throw new Error(`Could not create dependency ${node.tempId} -> ${edge.tempId}: ${dep.LatestResult?.CompleteMessage ?? 'unknown error'}`);
305
744
  }