@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.
- package/README.md +185 -0
- package/dist/TaskClaimStore.d.ts +11 -0
- package/dist/TaskClaimStore.d.ts.map +1 -1
- package/dist/TaskClaimStore.js +8 -3
- package/dist/TaskClaimStore.js.map +1 -1
- package/dist/TaskGraphDispatcher.d.ts +364 -2
- package/dist/TaskGraphDispatcher.d.ts.map +1 -1
- package/dist/TaskGraphDispatcher.js +1534 -37
- package/dist/TaskGraphDispatcher.js.map +1 -1
- package/dist/TaskGraphService.d.ts +110 -1
- package/dist/TaskGraphService.d.ts.map +1 -1
- package/dist/TaskGraphService.js +458 -19
- package/dist/TaskGraphService.js.map +1 -1
- package/dist/TaskLoopExecutor.d.ts +62 -0
- package/dist/TaskLoopExecutor.d.ts.map +1 -0
- package/dist/TaskLoopExecutor.js +248 -0
- package/dist/TaskLoopExecutor.js.map +1 -0
- package/dist/WorkflowSpecSync.d.ts +28 -2
- package/dist/WorkflowSpecSync.d.ts.map +1 -1
- package/dist/WorkflowSpecSync.js +83 -2
- package/dist/WorkflowSpecSync.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/operations/TaskGraphOperations.d.ts.map +1 -1
- package/dist/operations/TaskGraphOperations.js +4 -0
- package/dist/operations/TaskGraphOperations.js.map +1 -1
- package/dist/operations/WorkflowDraftOperation.d.ts +37 -0
- package/dist/operations/WorkflowDraftOperation.d.ts.map +1 -0
- package/dist/operations/WorkflowDraftOperation.js +141 -0
- package/dist/operations/WorkflowDraftOperation.js.map +1 -0
- package/dist/types.d.ts +114 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +10 -7
package/dist/TaskGraphService.js
CHANGED
|
@@ -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
|
|
97
|
-
// error, not a skipped node: silently dropping a task executes the graph with
|
|
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
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
}
|