@memberjunction/task-graph 0.0.0 → 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 (51) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +167 -27
  3. package/dist/DispatcherConditionEvaluator.d.ts +10 -0
  4. package/dist/DispatcherConditionEvaluator.d.ts.map +1 -0
  5. package/dist/DispatcherConditionEvaluator.js +27 -0
  6. package/dist/DispatcherConditionEvaluator.js.map +1 -0
  7. package/dist/TaskClaimStore.d.ts +125 -0
  8. package/dist/TaskClaimStore.d.ts.map +1 -0
  9. package/dist/TaskClaimStore.js +223 -0
  10. package/dist/TaskClaimStore.js.map +1 -0
  11. package/dist/TaskGraphDispatcher.d.ts +548 -0
  12. package/dist/TaskGraphDispatcher.d.ts.map +1 -0
  13. package/dist/TaskGraphDispatcher.js +2232 -0
  14. package/dist/TaskGraphDispatcher.js.map +1 -0
  15. package/dist/TaskGraphService.d.ts +247 -0
  16. package/dist/TaskGraphService.d.ts.map +1 -0
  17. package/dist/TaskGraphService.js +753 -0
  18. package/dist/TaskGraphService.js.map +1 -0
  19. package/dist/TaskGraphSubmitterImpl.d.ts +7 -0
  20. package/dist/TaskGraphSubmitterImpl.d.ts.map +1 -0
  21. package/dist/TaskGraphSubmitterImpl.js +52 -0
  22. package/dist/TaskGraphSubmitterImpl.js.map +1 -0
  23. package/dist/TaskLoopExecutor.d.ts +62 -0
  24. package/dist/TaskLoopExecutor.d.ts.map +1 -0
  25. package/dist/TaskLoopExecutor.js +248 -0
  26. package/dist/TaskLoopExecutor.js.map +1 -0
  27. package/dist/WorkflowSpecSync.d.ts +197 -0
  28. package/dist/WorkflowSpecSync.d.ts.map +1 -0
  29. package/dist/WorkflowSpecSync.js +474 -0
  30. package/dist/WorkflowSpecSync.js.map +1 -0
  31. package/dist/index.d.ts +19 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +19 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/operations/TaskGraphOperations.d.ts +39 -0
  36. package/dist/operations/TaskGraphOperations.d.ts.map +1 -0
  37. package/dist/operations/TaskGraphOperations.js +168 -0
  38. package/dist/operations/TaskGraphOperations.js.map +1 -0
  39. package/dist/operations/WorkflowDraftOperation.d.ts +37 -0
  40. package/dist/operations/WorkflowDraftOperation.d.ts.map +1 -0
  41. package/dist/operations/WorkflowDraftOperation.js +141 -0
  42. package/dist/operations/WorkflowDraftOperation.js.map +1 -0
  43. package/dist/operations/WorkflowOperations.d.ts +22 -0
  44. package/dist/operations/WorkflowOperations.d.ts.map +1 -0
  45. package/dist/operations/WorkflowOperations.js +99 -0
  46. package/dist/operations/WorkflowOperations.js.map +1 -0
  47. package/dist/types.d.ts +328 -0
  48. package/dist/types.d.ts.map +1 -0
  49. package/dist/types.js +9 -0
  50. package/dist/types.js.map +1 -0
  51. package/package.json +36 -8
@@ -0,0 +1,753 @@
1
+ /**
2
+ * @fileoverview Producer-agnostic submission of task graphs.
3
+ *
4
+ * Per D2, submission (validate + persist) is split from execution (the durable dispatcher). This
5
+ * class does the first half only: it validates a spec, resolves agent names, writes the parent +
6
+ * children + dependency edges, and returns immediately. Nothing here executes anything.
7
+ *
8
+ * That split is what makes the engine invocation-agnostic (D1): an agent emitting a graph, a
9
+ * scheduled job, a Slack message, and a future manual workflow UI all call the same `Submit`, and
10
+ * whichever dispatcher instance is running picks the work up. Callers never wait for execution, so
11
+ * no channel needs to hold a long-lived request open the way the old `ExecuteTaskGraph` mutation did.
12
+ *
13
+ * Per D11 the API is deliberately not AI-flavored — an LLM, deterministic code, or a human UI can
14
+ * all construct and submit a DAG.
15
+ *
16
+ * @module @memberjunction/task-graph
17
+ */
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';
21
+ /**
22
+ * Continuation chains are bounded separately from graph nesting.
23
+ *
24
+ * The spawn-depth cap governs graphs nested *by tasks*; this one governs graphs chained *by
25
+ * continuations* — an agent re-invoked with a finished graph's results can emit another graph, which
26
+ * re-invokes it again. Both loops exist and neither cap constrains the other, so both are needed. At
27
+ * the cap the dispatcher forces `continuation: 'message'`, which ends the chain without losing the
28
+ * results.
29
+ */
30
+ export const MAX_REINVOKE_DEPTH = 5;
31
+ /** What a parent row means when it carries no metadata, or metadata we cannot read. */
32
+ const DEFAULT_PARENT_METADATA = {
33
+ continuation: 'message',
34
+ reinvokeDepth: 0,
35
+ failureSemantics: 'block',
36
+ submittedByAgentRunID: null,
37
+ submittedByUserID: null,
38
+ };
39
+ /**
40
+ * Parses a parent Task's continuation metadata.
41
+ *
42
+ * Shared by the writer (`TaskGraphService`) and the reader (`TaskGraphDispatcher`) so the two cannot
43
+ * drift — the failure that shape invites is a graph that completes and then does nothing, because
44
+ * one side wrote a field the other never looked for.
45
+ *
46
+ * Unparseable input defaults to `message` rather than throwing. A row predating this metadata, or
47
+ * one a user hand-edited, is a legitimate state, and the right response to "I don't know what this
48
+ * graph wanted" is still to tell the user their work finished.
49
+ */
50
+ export function ParseTaskGraphParentMetadata(raw) {
51
+ if (!raw)
52
+ return { ...DEFAULT_PARENT_METADATA };
53
+ try {
54
+ const parsed = JSON.parse(raw);
55
+ if (!parsed || typeof parsed !== 'object')
56
+ return { ...DEFAULT_PARENT_METADATA };
57
+ return {
58
+ ...DEFAULT_PARENT_METADATA,
59
+ ...parsed,
60
+ // Guard the two fields the dispatcher branches on. A JSON round-trip, a hand edit, or a
61
+ // future producer can all supply the wrong type here, and a bad `reinvokeDepth` would
62
+ // either disable the cap (NaN comparisons are always false) or trip it immediately.
63
+ continuation: parsed.continuation === 'reinvoke' || parsed.continuation === 'none'
64
+ ? parsed.continuation
65
+ : 'message',
66
+ failureSemantics: parsed.failureSemantics === 'edges' ? 'edges' : 'block',
67
+ reinvokeDepth: Number.isFinite(parsed.reinvokeDepth) ? Number(parsed.reinvokeDepth) : 0,
68
+ };
69
+ }
70
+ catch {
71
+ return { ...DEFAULT_PARENT_METADATA };
72
+ }
73
+ }
74
+ /** True when a continuation chain has gone as far as it may. */
75
+ export function IsReinvokeCapReached(meta) {
76
+ return meta.reinvokeDepth >= MAX_REINVOKE_DEPTH;
77
+ }
78
+ /** Name of the task type used for agent-orchestrated graphs. */
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
+ }
255
+ export class TaskGraphService {
256
+ /**
257
+ * Validates and persists a task graph, returning as soon as it is durable.
258
+ *
259
+ * Deliberately does NOT start execution: the dispatcher discovers `Pending` work by polling
260
+ * claimable tasks, so submission and execution are decoupled even within a single process.
261
+ * A submitted graph therefore survives the submitting request, the submitting agent run, and
262
+ * the submitting server — which is the entire point of Task rows over in-run state (D8).
263
+ */
264
+ async Submit(spec, context) {
265
+ // 1. Structural validation. Server-side is the source of truth even when a producer already
266
+ // validated client-side — the same function runs in both places, so they cannot disagree.
267
+ const validation = ValidateTaskGraphSpec(spec);
268
+ if (!validation.Valid) {
269
+ const message = `Task graph "${spec.workflowName}" is invalid:\n${FormatValidationErrors(validation.Errors)}`;
270
+ LogError(`[TaskGraphService] ${message}`);
271
+ return { Success: false, ErrorMessage: message };
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
+ }
303
+ try {
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.
307
+ const agentIDsByName = await this.resolveAgents(spec, context);
308
+ if (!agentIDsByName.Success) {
309
+ return { Success: false, ErrorMessage: agentIDsByName.ErrorMessage };
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
+ }
319
+ const taskTypeID = await this.ensureTaskType(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
+ });
354
+ LogStatus(`[TaskGraphService] Submitted "${spec.workflowName}": parent ${parentTaskID}, ${taskIDMap.size} task(s). ` +
355
+ `Awaiting dispatcher pickup.`);
356
+ return { Success: true, ParentTaskID: parentTaskID, TaskIDMap: taskIDMap };
357
+ }
358
+ catch (e) {
359
+ const message = e instanceof Error ? e.message : String(e);
360
+ LogError(`[TaskGraphService] Submit failed for "${spec.workflowName}": ${message}`);
361
+ return { Success: false, ErrorMessage: message };
362
+ }
363
+ }
364
+ /**
365
+ * Cancels a graph and everything in it that has not already settled.
366
+ *
367
+ * Cancels children first: a parent marked `Cancelled` while children are still `Pending` would
368
+ * leave the dispatcher free to pick those children up, which is the opposite of what the caller
369
+ * asked for.
370
+ */
371
+ async Cancel(parentTaskID, context) {
372
+ try {
373
+ const children = await this.loadChildren(parentTaskID, context);
374
+ for (const child of children) {
375
+ // Terminal work is left alone — cancelling a completed task would rewrite history.
376
+ if (['Complete', 'Failed', 'Cancelled'].includes(child.Status))
377
+ continue;
378
+ child.Status = 'Cancelled';
379
+ if (!(await child.Save())) {
380
+ LogError(`[TaskGraphService] Failed to cancel task ${child.ID}: ${child.LatestResult?.CompleteMessage ?? 'unknown error'}`);
381
+ }
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);
390
+ const parent = await context.Provider.GetEntityObject('MJ: Tasks', context.ContextUser);
391
+ if (!(await parent.Load(parentTaskID)))
392
+ return false;
393
+ parent.Status = 'Cancelled';
394
+ parent.CompletedAt = new Date();
395
+ return await parent.Save();
396
+ }
397
+ catch (e) {
398
+ LogError(`[TaskGraphService] Cancel failed for ${parentTaskID}: ${e instanceof Error ? e.message : String(e)}`);
399
+ return false;
400
+ }
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
+ }
446
+ /**
447
+ * Returns a failed task to `Pending` so the dispatcher can run it again.
448
+ *
449
+ * Also clears any `Blocked` dependents, since they were only blocked because this task failed —
450
+ * leaving them blocked would make the retry pointless, as the graph still could not progress
451
+ * past this node.
452
+ */
453
+ async Retry(taskID, context) {
454
+ try {
455
+ const task = await context.Provider.GetEntityObject('MJ: Tasks', context.ContextUser);
456
+ if (!(await task.Load(taskID)))
457
+ return false;
458
+ if (task.Status !== 'Failed') {
459
+ LogError(`[TaskGraphService] Cannot retry task ${taskID}: status is ${task.Status}, expected Failed.`);
460
+ return false;
461
+ }
462
+ task.Status = 'Pending';
463
+ task.ErrorMessage = null;
464
+ task.StartedAt = null;
465
+ task.CompletedAt = null;
466
+ task.PercentComplete = 0;
467
+ // Clear any stale claim so the task is immediately claimable.
468
+ task.ClaimedBy = null;
469
+ task.ClaimExpiresAt = null;
470
+ if (!(await task.Save()))
471
+ return false;
472
+ if (task.ParentID) {
473
+ for (const sibling of await this.loadChildren(task.ParentID, context)) {
474
+ if (sibling.Status === 'Blocked') {
475
+ sibling.Status = 'Pending';
476
+ await sibling.Save();
477
+ }
478
+ }
479
+ }
480
+ return true;
481
+ }
482
+ catch (e) {
483
+ LogError(`[TaskGraphService] Retry failed for ${taskID}: ${e instanceof Error ? e.message : String(e)}`);
484
+ return false;
485
+ }
486
+ }
487
+ // ────────────────────────────────────────────────────────────────────────
488
+ // internals
489
+ // ────────────────────────────────────────────────────────────────────────
490
+ /** Maps every referenced agent name to its ID, or reports all unresolvable names at once. */
491
+ async resolveAgents(spec, context) {
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))];
496
+ if (names.length === 0)
497
+ return { Success: true, Map: new Map() };
498
+ const quoted = names.map((n) => `'${n.replace(/'/g, "''")}'`).join(',');
499
+ const result = await RunView.FromMetadataProvider(context.Provider).RunView({ EntityName: 'MJ: AI Agents', ExtraFilter: `Name IN (${quoted})`, Fields: ['ID', 'Name'], ResultType: 'simple' }, context.ContextUser);
500
+ const found = new Map((result.Results ?? []).map((r) => [r.Name, r.ID]));
501
+ const missing = names.filter((n) => !found.has(n));
502
+ if (missing.length > 0) {
503
+ return {
504
+ Success: false,
505
+ ErrorMessage: `Task graph "${spec.workflowName}" references ${missing.length} unknown agent(s): ${missing.join(', ')}. ` +
506
+ `Submitting would execute the graph with holes where those tasks should be.`,
507
+ };
508
+ }
509
+ return { Success: true, Map: found };
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
+ }
559
+ /** Finds or creates the task type used for orchestrated graphs. */
560
+ async ensureTaskType(context) {
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);
562
+ const found = existing.Results?.[0]?.ID;
563
+ if (found)
564
+ return found;
565
+ const tt = await context.Provider.GetEntityObject('MJ: Task Types', context.ContextUser);
566
+ tt.NewRecord();
567
+ tt.Name = TASK_TYPE_NAME;
568
+ tt.Description = 'Tasks created by agent-orchestrated workflows.';
569
+ if (!(await tt.Save())) {
570
+ throw new Error(`Could not create task type: ${tt.LatestResult?.CompleteMessage ?? 'unknown error'}`);
571
+ }
572
+ return tt.ID;
573
+ }
574
+ /** Writes the parent task that represents the graph as a whole. */
575
+ async persistParent(spec, taskTypeID, context) {
576
+ const parent = await context.Provider.GetEntityObject('MJ: Tasks', context.ContextUser);
577
+ parent.NewRecord();
578
+ parent.Name = spec.workflowName;
579
+ parent.Description = spec.reasoning || 'Orchestrated workflow';
580
+ parent.TypeID = taskTypeID;
581
+ parent.EnvironmentID = context.EnvironmentID;
582
+ parent.ConversationDetailID = context.ConversationDetailID ?? null;
583
+ parent.Status = 'In Progress';
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;
590
+ // The parent row carries what happens AFTER the graph settles. It lives here rather than in
591
+ // dispatcher memory because the dispatcher that finishes a graph is frequently not the
592
+ // process that accepted it — a restart, a second instance, or simply a long-running graph
593
+ // all break that assumption. Anything the completion path needs has to be durable too.
594
+ parent.InputPayload = JSON.stringify({
595
+ continuation: spec.continuation ?? 'message',
596
+ reinvokeDepth: context.ReinvokeDepth ?? 0,
597
+ failureSemantics: spec.failureSemantics ?? 'block',
598
+ submittedByAgentRunID: context.AgentRunID ?? null,
599
+ submittedByUserID: context.ContextUser?.ID ?? null,
600
+ });
601
+ if (!(await parent.Save())) {
602
+ throw new Error(`Could not create parent task: ${parent.LatestResult?.CompleteMessage ?? 'unknown error'}`);
603
+ }
604
+ return parent.ID;
605
+ }
606
+ /** Writes each child task, returning the tempId -> real ID mapping edges will need. */
607
+ async persistChildren(spec, parentTaskID, taskTypeID, agentIDsByName, actionIDsByName, promptIDsByName, context) {
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 }))));
612
+ for (const node of spec.tasks) {
613
+ const task = await context.Provider.GetEntityObject('MJ: Tasks', context.ContextUser);
614
+ task.NewRecord();
615
+ task.Name = node.name;
616
+ task.Description = node.description;
617
+ task.TypeID = taskTypeID;
618
+ task.EnvironmentID = context.EnvironmentID;
619
+ task.ParentID = parentTaskID;
620
+ task.ConversationDetailID = context.ConversationDetailID ?? null;
621
+ task.Status = 'Pending';
622
+ task.PercentComplete = 0;
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.`);
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);
686
+ // Input rides in its own column; Description stays human-readable.
687
+ task.InputPayload = node.inputPayload ? JSON.stringify(node.inputPayload) : null;
688
+ if (!(await task.Save())) {
689
+ throw new Error(`Could not create task "${node.name}": ${task.LatestResult?.CompleteMessage ?? 'unknown error'}`);
690
+ }
691
+ map.set(node.tempId, task.ID);
692
+ }
693
+ return map;
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
+ }
714
+ /** Writes the dependency edges, translating tempIds to persisted IDs. */
715
+ async persistDependencies(spec, taskIDMap, context) {
716
+ for (const node of spec.tasks) {
717
+ const taskID = taskIDMap.get(node.tempId);
718
+ if (!taskID)
719
+ continue;
720
+ for (const raw of node.dependsOn ?? []) {
721
+ const edge = NormalizeDependency(raw);
722
+ const dependsOnTaskID = taskIDMap.get(edge.tempId);
723
+ if (!dependsOnTaskID)
724
+ continue; // validation already rejected unknown refs
725
+ const dep = await context.Provider.GetEntityObject('MJ: Task Dependencies', context.ContextUser);
726
+ dep.NewRecord();
727
+ dep.TaskID = taskID;
728
+ dep.DependsOnTaskID = dependsOnTaskID;
729
+ dep.DependencyType = edge.dependencyType ?? 'Prerequisite';
730
+ // NULL for an unconditional edge, matching AIAgentStepPath — so a graph authored in
731
+ // the flow editor and one emitted by an agent store the same thing.
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;
742
+ if (!(await dep.Save())) {
743
+ throw new Error(`Could not create dependency ${node.tempId} -> ${edge.tempId}: ${dep.LatestResult?.CompleteMessage ?? 'unknown error'}`);
744
+ }
745
+ }
746
+ }
747
+ }
748
+ async loadChildren(parentTaskID, context) {
749
+ const result = await RunView.FromMetadataProvider(context.Provider).RunView({ EntityName: 'MJ: Tasks', ExtraFilter: `ParentID='${parentTaskID}'`, ResultType: 'entity_object' }, context.ContextUser);
750
+ return (result.Success ? result.Results : []) ?? [];
751
+ }
752
+ }
753
+ //# sourceMappingURL=TaskGraphService.js.map