@memberjunction/task-graph 0.0.0 → 6.1.0-edge.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +7 -0
- package/dist/DispatcherConditionEvaluator.d.ts +10 -0
- package/dist/DispatcherConditionEvaluator.d.ts.map +1 -0
- package/dist/DispatcherConditionEvaluator.js +27 -0
- package/dist/DispatcherConditionEvaluator.js.map +1 -0
- package/dist/TaskClaimStore.d.ts +114 -0
- package/dist/TaskClaimStore.d.ts.map +1 -0
- package/dist/TaskClaimStore.js +218 -0
- package/dist/TaskClaimStore.js.map +1 -0
- package/dist/TaskGraphDispatcher.d.ts +186 -0
- package/dist/TaskGraphDispatcher.d.ts.map +1 -0
- package/dist/TaskGraphDispatcher.js +735 -0
- package/dist/TaskGraphDispatcher.js.map +1 -0
- package/dist/TaskGraphService.d.ts +138 -0
- package/dist/TaskGraphService.d.ts.map +1 -0
- package/dist/TaskGraphService.js +314 -0
- package/dist/TaskGraphService.js.map +1 -0
- package/dist/TaskGraphSubmitterImpl.d.ts +7 -0
- package/dist/TaskGraphSubmitterImpl.d.ts.map +1 -0
- package/dist/TaskGraphSubmitterImpl.js +52 -0
- package/dist/TaskGraphSubmitterImpl.js.map +1 -0
- package/dist/WorkflowSpecSync.d.ts +171 -0
- package/dist/WorkflowSpecSync.d.ts.map +1 -0
- package/dist/WorkflowSpecSync.js +393 -0
- package/dist/WorkflowSpecSync.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/operations/TaskGraphOperations.d.ts +39 -0
- package/dist/operations/TaskGraphOperations.d.ts.map +1 -0
- package/dist/operations/TaskGraphOperations.js +164 -0
- package/dist/operations/TaskGraphOperations.js.map +1 -0
- package/dist/operations/WorkflowOperations.d.ts +22 -0
- package/dist/operations/WorkflowOperations.d.ts.map +1 -0
- package/dist/operations/WorkflowOperations.js +99 -0
- package/dist/operations/WorkflowOperations.js.map +1 -0
- package/dist/types.d.ts +214 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/package.json +33 -8
- package/README.md +0 -45
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 MemberJunction
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { IConditionEvaluator } from '@memberjunction/ai-core-plus';
|
|
2
|
+
export declare class DispatcherConditionEvaluator implements IConditionEvaluator {
|
|
3
|
+
private readonly evaluator;
|
|
4
|
+
Evaluate(expression: string, context: Record<string, unknown>): {
|
|
5
|
+
Success: boolean;
|
|
6
|
+
Value?: unknown;
|
|
7
|
+
ErrorMessage?: string;
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=DispatcherConditionEvaluator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"DispatcherConditionEvaluator.d.ts","sourceRoot":"","sources":["../src/DispatcherConditionEvaluator.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAExE,qBAAa,4BAA6B,YAAW,mBAAmB;IACpE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAiC;IAEpD,QAAQ,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,OAAO,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,CAAA;KAAE;CAUtI"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Condition evaluation for durable graph edges.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the same `SafeExpressionEvaluator` the design-time flow executor uses. Sharing the evaluator
|
|
5
|
+
* is not incidental — it is what makes an edge condition mean the same thing whether it was drawn in
|
|
6
|
+
* the flow editor or emitted by an agent, which is the premise Save as Workflow (D17) rests on.
|
|
7
|
+
*
|
|
8
|
+
* @module @memberjunction/task-graph
|
|
9
|
+
*/
|
|
10
|
+
import { SafeExpressionEvaluator } from '@memberjunction/global';
|
|
11
|
+
export class DispatcherConditionEvaluator {
|
|
12
|
+
constructor() {
|
|
13
|
+
this.evaluator = new SafeExpressionEvaluator();
|
|
14
|
+
}
|
|
15
|
+
Evaluate(expression, context) {
|
|
16
|
+
try {
|
|
17
|
+
const result = this.evaluator.evaluate(expression, context);
|
|
18
|
+
return result.success
|
|
19
|
+
? { Success: true, Value: result.value }
|
|
20
|
+
: { Success: false, ErrorMessage: String(result.error ?? 'condition evaluation failed') };
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
return { Success: false, ErrorMessage: e instanceof Error ? e.message : String(e) };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=DispatcherConditionEvaluator.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"DispatcherConditionEvaluator.js","sourceRoot":"","sources":["../src/DispatcherConditionEvaluator.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,uBAAuB,EAAE,MAAM,wBAAwB,CAAC;AAGjE,MAAM,OAAO,4BAA4B;IAAzC;QACqB,cAAS,GAAG,IAAI,uBAAuB,EAAE,CAAC;IAY/D,CAAC;IAVU,QAAQ,CAAC,UAAkB,EAAE,OAAgC;QAChE,IAAI,CAAC;YACD,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC;YAC5D,OAAO,MAAM,CAAC,OAAO;gBACjB,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE;gBACxC,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,YAAY,EAAE,MAAM,CAAC,MAAM,CAAC,KAAK,IAAI,6BAA6B,CAAC,EAAE,CAAC;QAClG,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACT,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,YAAY,EAAE,CAAC,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC;QACxF,CAAC;IACL,CAAC;CACJ"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The compare-and-swap claim protocol for durable task execution.
|
|
3
|
+
*
|
|
4
|
+
* This is the mechanism that lets more than one dispatcher instance work the same task table
|
|
5
|
+
* without two of them running the same task, and that lets a crashed instance's work be picked up
|
|
6
|
+
* rather than stranded. It is deliberately a small, self-contained unit: every state transition is
|
|
7
|
+
* a guarded `UPDATE ... WHERE <expected state>` whose rowcount is the answer, so correctness rests
|
|
8
|
+
* on the database's own atomicity rather than on a distributed lock manager.
|
|
9
|
+
*
|
|
10
|
+
* **Why rowcount and not read-then-write.** Reading a task, deciding it is claimable, then writing
|
|
11
|
+
* the claim is a textbook race: two instances can both read `Pending`. The single-statement form —
|
|
12
|
+
* `UPDATE Task SET ClaimedBy=@me WHERE ID=@id AND Status='Pending'` — makes the check and the write
|
|
13
|
+
* one atomic operation, so exactly one instance sees rowcount 1 and the other sees 0 and moves on.
|
|
14
|
+
*
|
|
15
|
+
* **Why every transition is guarded, not just the initial claim.** Per D20 the Task table stays
|
|
16
|
+
* user-writable: entity forms, Data Explorer, GraphQL, and any agent holding an update-record action
|
|
17
|
+
* can change `Status` or clear `ClaimedBy` underneath a running executor. A completion write that
|
|
18
|
+
* only said "set this task Complete" would happily overwrite a task someone had reassigned. Guarding
|
|
19
|
+
* on `ClaimedBy=@me` means a stale executor's write fails cleanly (rowcount 0) instead of
|
|
20
|
+
* double-completing, and the dispatcher can defer to the sweep.
|
|
21
|
+
*
|
|
22
|
+
* @module @memberjunction/task-graph
|
|
23
|
+
*/
|
|
24
|
+
import { IMetadataProvider, UserInfo } from '@memberjunction/core';
|
|
25
|
+
import { ReconciliationEvent } from './types.js';
|
|
26
|
+
/** Fields the claim protocol needs from a candidate task. */
|
|
27
|
+
export type ClaimableTask = {
|
|
28
|
+
ID: string;
|
|
29
|
+
Name: string;
|
|
30
|
+
AgentID: string | null;
|
|
31
|
+
UserID: string | null;
|
|
32
|
+
InputPayload: string | null;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Guarded reads and writes over the `Task` claim columns.
|
|
36
|
+
*
|
|
37
|
+
* Uses direct SQL rather than `BaseEntity.Save()` on purpose, and this is the one place in the
|
|
38
|
+
* program where that is correct: the entire point is a *conditional* write whose rowcount is the
|
|
39
|
+
* return value. `Save()` issues an unconditional update and reports success for a row whose state
|
|
40
|
+
* changed underneath it, which is precisely the race being defended against. Every method here is a
|
|
41
|
+
* single statement; nothing reads-then-writes.
|
|
42
|
+
*/
|
|
43
|
+
export declare class TaskClaimStore {
|
|
44
|
+
private readonly instanceID;
|
|
45
|
+
private readonly claimTTLSeconds;
|
|
46
|
+
constructor(instanceID: string, claimTTLSeconds: number);
|
|
47
|
+
private sql;
|
|
48
|
+
/** Schema-qualified `Task` table for the provider's configured core schema. */
|
|
49
|
+
private taskTable;
|
|
50
|
+
/**
|
|
51
|
+
* Attempts to claim one task.
|
|
52
|
+
*
|
|
53
|
+
* The `Status='Pending'` predicate is the whole contract: a task another instance already moved
|
|
54
|
+
* to `In Progress` fails the predicate and yields rowcount 0. `ClaimedBy IS NULL OR
|
|
55
|
+
* ClaimExpiresAt < now` additionally lets an expired claim be taken over without a separate
|
|
56
|
+
* reconciliation pass having to run first.
|
|
57
|
+
*
|
|
58
|
+
* @returns true when this instance now owns the task
|
|
59
|
+
*/
|
|
60
|
+
TryClaim(provider: IMetadataProvider, taskID: string, contextUser: UserInfo): Promise<boolean>;
|
|
61
|
+
/**
|
|
62
|
+
* Extends this instance's claim on a task it is actively running.
|
|
63
|
+
*
|
|
64
|
+
* Guarded on `ClaimedBy=@me` so a heartbeat can never resurrect a claim that reconciliation
|
|
65
|
+
* already released — if the sweep took the task back, the heartbeat fails and the executor
|
|
66
|
+
* learns its work is no longer owned.
|
|
67
|
+
*
|
|
68
|
+
* @returns true when the claim was extended; false means this instance no longer owns the task
|
|
69
|
+
*/
|
|
70
|
+
Heartbeat(provider: IMetadataProvider, taskID: string, contextUser: UserInfo): Promise<boolean>;
|
|
71
|
+
/**
|
|
72
|
+
* Records a terminal outcome and releases the claim in one guarded statement.
|
|
73
|
+
*
|
|
74
|
+
* Guarded on both `Status='In Progress'` and `ClaimedBy=@me`: a task that was cancelled or
|
|
75
|
+
* reassigned while running fails the predicate, so a stale executor cannot overwrite the newer
|
|
76
|
+
* decision. The caller treats rowcount 0 as "someone else owns this now" rather than an error.
|
|
77
|
+
*
|
|
78
|
+
* @returns true when this instance's outcome was recorded
|
|
79
|
+
*/
|
|
80
|
+
CompleteClaimed(provider: IMetadataProvider, taskID: string, outcome: {
|
|
81
|
+
Status: 'Complete' | 'Failed';
|
|
82
|
+
OutputPayload?: string | null;
|
|
83
|
+
ErrorMessage?: string | null;
|
|
84
|
+
AgentRunID?: string | null;
|
|
85
|
+
}, contextUser: UserInfo): Promise<boolean>;
|
|
86
|
+
/**
|
|
87
|
+
* Reclaims tasks whose claims have lapsed, returning them to `Pending` so any instance can pick
|
|
88
|
+
* them up.
|
|
89
|
+
*
|
|
90
|
+
* **Human tasks are exempt** (review round 2). A task assigned to a person (`UserID` set) never
|
|
91
|
+
* carries a claim, so `In Progress` with no claim is its *legitimate* parked shape — an approval
|
|
92
|
+
* waiting on someone. Normalizing it would reset that approval out from under the user. Their
|
|
93
|
+
* lifecycle is driven by `DueAt` notification and escalation, never by claim expiry.
|
|
94
|
+
*
|
|
95
|
+
* Only expired claims are reclaimed; a live claim is left strictly alone, which is what keeps a
|
|
96
|
+
* slow-but-healthy task from being executed twice.
|
|
97
|
+
*/
|
|
98
|
+
ReleaseExpiredClaims(provider: IMetadataProvider, contextUser: UserInfo): Promise<ReconciliationEvent[]>;
|
|
99
|
+
/**
|
|
100
|
+
* Returns *agent* tasks sitting `In Progress` with no claim at all.
|
|
101
|
+
*
|
|
102
|
+
* This is the anomalous shape D20 anticipates from a human or an agent writing `Status`
|
|
103
|
+
* directly. It is reported rather than silently corrected: the row is evidence of tampering or
|
|
104
|
+
* of a bug, and Record Changes already carries the audit trail. Human-assigned tasks are
|
|
105
|
+
* excluded because for them this shape is legitimate, not anomalous.
|
|
106
|
+
*/
|
|
107
|
+
FindOrphanedInProgress(provider: IMetadataProvider, contextUser: UserInfo): Promise<ReconciliationEvent[]>;
|
|
108
|
+
/** Runs the affected-rows statement, returning 0 on error rather than throwing into the loop. */
|
|
109
|
+
private affectedRows;
|
|
110
|
+
private literalOrNull;
|
|
111
|
+
/** Single-quote escaping. Inputs here are UUIDs and JSON the dispatcher itself produced. */
|
|
112
|
+
private escape;
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=TaskClaimStore.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TaskClaimStore.d.ts","sourceRoot":"","sources":["../src/TaskClaimStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,EAAE,iBAAiB,EAA6C,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAC9G,OAAO,EAAE,mBAAmB,EAAE,MAAM,SAAS,CAAC;AAE9C,6DAA6D;AAC7D,MAAM,MAAM,aAAa,GAAG;IACxB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/B,CAAC;AAEF;;;;;;;;GAQG;AACH,qBAAa,cAAc;IAEnB,OAAO,CAAC,QAAQ,CAAC,UAAU;IAC3B,OAAO,CAAC,QAAQ,CAAC,eAAe;gBADf,UAAU,EAAE,MAAM,EAClB,eAAe,EAAE,MAAM;IAG5C,OAAO,CAAC,GAAG;IAIX,+EAA+E;IAC/E,OAAO,CAAC,SAAS;IAKjB;;;;;;;;;OASG;IACU,QAAQ,CAAC,QAAQ,EAAE,iBAAiB,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC;IAiB3G;;;;;;;;OAQG;IACU,SAAS,CAAC,QAAQ,EAAE,iBAAiB,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,EAAE,QAAQ,GAAG,OAAO,CAAC,OAAO,CAAC;IAY5G;;;;;;;;OAQG;IACU,eAAe,CACxB,QAAQ,EAAE,iBAAiB,EAC3B,MAAM,EAAE,MAAM,EACd,OAAO,EAAE;QAAE,MAAM,EAAE,UAAU,GAAG,QAAQ,CAAC;QAAC,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,EACnI,WAAW,EAAE,QAAQ,GACtB,OAAO,CAAC,OAAO,CAAC;IAwBnB;;;;;;;;;;;OAWG;IACU,oBAAoB,CAAC,QAAQ,EAAE,iBAAiB,EAAE,WAAW,EAAE,QAAQ,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC;IA2CrH;;;;;;;OAOG;IACU,sBAAsB,CAAC,QAAQ,EAAE,iBAAiB,EAAE,WAAW,EAAE,QAAQ,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAqBvH,iGAAiG;YACnF,YAAY;IAe1B,OAAO,CAAC,aAAa;IAIrB,4FAA4F;IAC5F,OAAO,CAAC,MAAM;CAGjB"}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview The compare-and-swap claim protocol for durable task execution.
|
|
3
|
+
*
|
|
4
|
+
* This is the mechanism that lets more than one dispatcher instance work the same task table
|
|
5
|
+
* without two of them running the same task, and that lets a crashed instance's work be picked up
|
|
6
|
+
* rather than stranded. It is deliberately a small, self-contained unit: every state transition is
|
|
7
|
+
* a guarded `UPDATE ... WHERE <expected state>` whose rowcount is the answer, so correctness rests
|
|
8
|
+
* on the database's own atomicity rather than on a distributed lock manager.
|
|
9
|
+
*
|
|
10
|
+
* **Why rowcount and not read-then-write.** Reading a task, deciding it is claimable, then writing
|
|
11
|
+
* the claim is a textbook race: two instances can both read `Pending`. The single-statement form —
|
|
12
|
+
* `UPDATE Task SET ClaimedBy=@me WHERE ID=@id AND Status='Pending'` — makes the check and the write
|
|
13
|
+
* one atomic operation, so exactly one instance sees rowcount 1 and the other sees 0 and moves on.
|
|
14
|
+
*
|
|
15
|
+
* **Why every transition is guarded, not just the initial claim.** Per D20 the Task table stays
|
|
16
|
+
* user-writable: entity forms, Data Explorer, GraphQL, and any agent holding an update-record action
|
|
17
|
+
* can change `Status` or clear `ClaimedBy` underneath a running executor. A completion write that
|
|
18
|
+
* only said "set this task Complete" would happily overwrite a task someone had reassigned. Guarding
|
|
19
|
+
* on `ClaimedBy=@me` means a stale executor's write fails cleanly (rowcount 0) instead of
|
|
20
|
+
* double-completing, and the dispatcher can defer to the sweep.
|
|
21
|
+
*
|
|
22
|
+
* @module @memberjunction/task-graph
|
|
23
|
+
*/
|
|
24
|
+
import { LogError, LogStatus } from '@memberjunction/core';
|
|
25
|
+
/**
|
|
26
|
+
* Guarded reads and writes over the `Task` claim columns.
|
|
27
|
+
*
|
|
28
|
+
* Uses direct SQL rather than `BaseEntity.Save()` on purpose, and this is the one place in the
|
|
29
|
+
* program where that is correct: the entire point is a *conditional* write whose rowcount is the
|
|
30
|
+
* return value. `Save()` issues an unconditional update and reports success for a row whose state
|
|
31
|
+
* changed underneath it, which is precisely the race being defended against. Every method here is a
|
|
32
|
+
* single statement; nothing reads-then-writes.
|
|
33
|
+
*/
|
|
34
|
+
export class TaskClaimStore {
|
|
35
|
+
constructor(instanceID, claimTTLSeconds) {
|
|
36
|
+
this.instanceID = instanceID;
|
|
37
|
+
this.claimTTLSeconds = claimTTLSeconds;
|
|
38
|
+
}
|
|
39
|
+
sql(provider) {
|
|
40
|
+
return provider;
|
|
41
|
+
}
|
|
42
|
+
/** Schema-qualified `Task` table for the provider's configured core schema. */
|
|
43
|
+
taskTable(provider) {
|
|
44
|
+
const db = this.sql(provider);
|
|
45
|
+
return `${db.QuoteIdentifier(db.MJCoreSchemaName)}.${db.QuoteIdentifier('Task')}`;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Attempts to claim one task.
|
|
49
|
+
*
|
|
50
|
+
* The `Status='Pending'` predicate is the whole contract: a task another instance already moved
|
|
51
|
+
* to `In Progress` fails the predicate and yields rowcount 0. `ClaimedBy IS NULL OR
|
|
52
|
+
* ClaimExpiresAt < now` additionally lets an expired claim be taken over without a separate
|
|
53
|
+
* reconciliation pass having to run first.
|
|
54
|
+
*
|
|
55
|
+
* @returns true when this instance now owns the task
|
|
56
|
+
*/
|
|
57
|
+
async TryClaim(provider, taskID, contextUser) {
|
|
58
|
+
const db = this.sql(provider);
|
|
59
|
+
const expires = new Date(Date.now() + this.claimTTLSeconds * 1000);
|
|
60
|
+
const sql = `
|
|
61
|
+
UPDATE ${this.taskTable(provider)}
|
|
62
|
+
SET ${db.QuoteIdentifier('Status')} = 'In Progress',
|
|
63
|
+
${db.QuoteIdentifier('ClaimedBy')} = '${this.escape(this.instanceID)}',
|
|
64
|
+
${db.QuoteIdentifier('ClaimExpiresAt')} = '${expires.toISOString()}',
|
|
65
|
+
${db.QuoteIdentifier('StartedAt')} = '${new Date().toISOString()}'
|
|
66
|
+
WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(taskID)}'
|
|
67
|
+
AND ${db.QuoteIdentifier('Status')} = 'Pending'
|
|
68
|
+
AND (${db.QuoteIdentifier('ClaimedBy')} IS NULL
|
|
69
|
+
OR ${db.QuoteIdentifier('ClaimExpiresAt')} IS NULL
|
|
70
|
+
OR ${db.QuoteIdentifier('ClaimExpiresAt')} < '${new Date().toISOString()}')`;
|
|
71
|
+
return (await this.affectedRows(db, sql, contextUser)) === 1;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Extends this instance's claim on a task it is actively running.
|
|
75
|
+
*
|
|
76
|
+
* Guarded on `ClaimedBy=@me` so a heartbeat can never resurrect a claim that reconciliation
|
|
77
|
+
* already released — if the sweep took the task back, the heartbeat fails and the executor
|
|
78
|
+
* learns its work is no longer owned.
|
|
79
|
+
*
|
|
80
|
+
* @returns true when the claim was extended; false means this instance no longer owns the task
|
|
81
|
+
*/
|
|
82
|
+
async Heartbeat(provider, taskID, contextUser) {
|
|
83
|
+
const db = this.sql(provider);
|
|
84
|
+
const expires = new Date(Date.now() + this.claimTTLSeconds * 1000);
|
|
85
|
+
const sql = `
|
|
86
|
+
UPDATE ${this.taskTable(provider)}
|
|
87
|
+
SET ${db.QuoteIdentifier('ClaimExpiresAt')} = '${expires.toISOString()}'
|
|
88
|
+
WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(taskID)}'
|
|
89
|
+
AND ${db.QuoteIdentifier('ClaimedBy')} = '${this.escape(this.instanceID)}'
|
|
90
|
+
AND ${db.QuoteIdentifier('Status')} = 'In Progress'`;
|
|
91
|
+
return (await this.affectedRows(db, sql, contextUser)) === 1;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Records a terminal outcome and releases the claim in one guarded statement.
|
|
95
|
+
*
|
|
96
|
+
* Guarded on both `Status='In Progress'` and `ClaimedBy=@me`: a task that was cancelled or
|
|
97
|
+
* reassigned while running fails the predicate, so a stale executor cannot overwrite the newer
|
|
98
|
+
* decision. The caller treats rowcount 0 as "someone else owns this now" rather than an error.
|
|
99
|
+
*
|
|
100
|
+
* @returns true when this instance's outcome was recorded
|
|
101
|
+
*/
|
|
102
|
+
async CompleteClaimed(provider, taskID, outcome, contextUser) {
|
|
103
|
+
const db = this.sql(provider);
|
|
104
|
+
const sets = [
|
|
105
|
+
`${db.QuoteIdentifier('Status')} = '${outcome.Status}'`,
|
|
106
|
+
`${db.QuoteIdentifier('CompletedAt')} = '${new Date().toISOString()}'`,
|
|
107
|
+
`${db.QuoteIdentifier('PercentComplete')} = ${outcome.Status === 'Complete' ? 100 : 0}`,
|
|
108
|
+
// Release the claim as part of the same atomic write — a separate release could be
|
|
109
|
+
// interrupted, leaving a terminal task holding a claim that the sweep would then flag.
|
|
110
|
+
`${db.QuoteIdentifier('ClaimedBy')} = NULL`,
|
|
111
|
+
`${db.QuoteIdentifier('ClaimExpiresAt')} = NULL`,
|
|
112
|
+
];
|
|
113
|
+
sets.push(`${db.QuoteIdentifier('OutputPayload')} = ${this.literalOrNull(outcome.OutputPayload)}`);
|
|
114
|
+
sets.push(`${db.QuoteIdentifier('ErrorMessage')} = ${this.literalOrNull(outcome.ErrorMessage)}`);
|
|
115
|
+
sets.push(`${db.QuoteIdentifier('AgentRunID')} = ${outcome.AgentRunID ? `'${this.escape(outcome.AgentRunID)}'` : 'NULL'}`);
|
|
116
|
+
const sql = `
|
|
117
|
+
UPDATE ${this.taskTable(provider)}
|
|
118
|
+
SET ${sets.join(', ')}
|
|
119
|
+
WHERE ${db.QuoteIdentifier('ID')} = '${this.escape(taskID)}'
|
|
120
|
+
AND ${db.QuoteIdentifier('Status')} = 'In Progress'
|
|
121
|
+
AND ${db.QuoteIdentifier('ClaimedBy')} = '${this.escape(this.instanceID)}'`;
|
|
122
|
+
return (await this.affectedRows(db, sql, contextUser)) === 1;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Reclaims tasks whose claims have lapsed, returning them to `Pending` so any instance can pick
|
|
126
|
+
* them up.
|
|
127
|
+
*
|
|
128
|
+
* **Human tasks are exempt** (review round 2). A task assigned to a person (`UserID` set) never
|
|
129
|
+
* carries a claim, so `In Progress` with no claim is its *legitimate* parked shape — an approval
|
|
130
|
+
* waiting on someone. Normalizing it would reset that approval out from under the user. Their
|
|
131
|
+
* lifecycle is driven by `DueAt` notification and escalation, never by claim expiry.
|
|
132
|
+
*
|
|
133
|
+
* Only expired claims are reclaimed; a live claim is left strictly alone, which is what keeps a
|
|
134
|
+
* slow-but-healthy task from being executed twice.
|
|
135
|
+
*/
|
|
136
|
+
async ReleaseExpiredClaims(provider, contextUser) {
|
|
137
|
+
const db = this.sql(provider);
|
|
138
|
+
const now = new Date().toISOString();
|
|
139
|
+
// Capture what will be reclaimed BEFORE reclaiming, so the log names the tasks. The
|
|
140
|
+
// subsequent UPDATE re-states the same predicate, so a task whose claim was refreshed in
|
|
141
|
+
// between is correctly skipped rather than reclaimed on stale information.
|
|
142
|
+
const candidates = await db.ExecuteSQL(`SELECT ${db.QuoteIdentifier('ID')}, ${db.QuoteIdentifier('Name')}, ${db.QuoteIdentifier('ClaimedBy')}
|
|
143
|
+
FROM ${this.taskTable(provider)}
|
|
144
|
+
WHERE ${db.QuoteIdentifier('Status')} = 'In Progress'
|
|
145
|
+
AND ${db.QuoteIdentifier('AgentID')} IS NOT NULL
|
|
146
|
+
AND ${db.QuoteIdentifier('ClaimedBy')} IS NOT NULL
|
|
147
|
+
AND ${db.QuoteIdentifier('ClaimExpiresAt')} IS NOT NULL
|
|
148
|
+
AND ${db.QuoteIdentifier('ClaimExpiresAt')} < '${now}'`, undefined, undefined, contextUser);
|
|
149
|
+
if (!candidates || candidates.length === 0)
|
|
150
|
+
return [];
|
|
151
|
+
const sql = `
|
|
152
|
+
UPDATE ${this.taskTable(provider)}
|
|
153
|
+
SET ${db.QuoteIdentifier('Status')} = 'Pending',
|
|
154
|
+
${db.QuoteIdentifier('ClaimedBy')} = NULL,
|
|
155
|
+
${db.QuoteIdentifier('ClaimExpiresAt')} = NULL
|
|
156
|
+
WHERE ${db.QuoteIdentifier('Status')} = 'In Progress'
|
|
157
|
+
AND ${db.QuoteIdentifier('AgentID')} IS NOT NULL
|
|
158
|
+
AND ${db.QuoteIdentifier('ClaimedBy')} IS NOT NULL
|
|
159
|
+
AND ${db.QuoteIdentifier('ClaimExpiresAt')} IS NOT NULL
|
|
160
|
+
AND ${db.QuoteIdentifier('ClaimExpiresAt')} < '${now}'`;
|
|
161
|
+
const released = await this.affectedRows(db, sql, contextUser);
|
|
162
|
+
const events = candidates.slice(0, released).map((c) => ({
|
|
163
|
+
TaskID: c.ID,
|
|
164
|
+
Action: 'ExpiredClaimReleased',
|
|
165
|
+
Detail: `Claim held by '${c.ClaimedBy}' expired; task '${c.Name}' returned to Pending.`,
|
|
166
|
+
}));
|
|
167
|
+
for (const e of events) {
|
|
168
|
+
LogStatus(`[TaskGraph reconciliation] ${e.Action}: ${e.Detail}`);
|
|
169
|
+
}
|
|
170
|
+
return events;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Returns *agent* tasks sitting `In Progress` with no claim at all.
|
|
174
|
+
*
|
|
175
|
+
* This is the anomalous shape D20 anticipates from a human or an agent writing `Status`
|
|
176
|
+
* directly. It is reported rather than silently corrected: the row is evidence of tampering or
|
|
177
|
+
* of a bug, and Record Changes already carries the audit trail. Human-assigned tasks are
|
|
178
|
+
* excluded because for them this shape is legitimate, not anomalous.
|
|
179
|
+
*/
|
|
180
|
+
async FindOrphanedInProgress(provider, contextUser) {
|
|
181
|
+
const db = this.sql(provider);
|
|
182
|
+
const rows = await db.ExecuteSQL(`SELECT ${db.QuoteIdentifier('ID')}, ${db.QuoteIdentifier('Name')}
|
|
183
|
+
FROM ${this.taskTable(provider)}
|
|
184
|
+
WHERE ${db.QuoteIdentifier('Status')} = 'In Progress'
|
|
185
|
+
AND ${db.QuoteIdentifier('AgentID')} IS NOT NULL
|
|
186
|
+
AND ${db.QuoteIdentifier('ClaimedBy')} IS NULL`, undefined, undefined, contextUser);
|
|
187
|
+
const events = (rows ?? []).map((r) => ({
|
|
188
|
+
TaskID: r.ID,
|
|
189
|
+
Action: 'OrphanedInProgressReleased',
|
|
190
|
+
Detail: `Agent task '${r.Name}' is In Progress with no claim — no dispatcher owns it.`,
|
|
191
|
+
}));
|
|
192
|
+
for (const e of events) {
|
|
193
|
+
LogError(`[TaskGraph reconciliation] ${e.Action}: ${e.Detail}`);
|
|
194
|
+
}
|
|
195
|
+
return events;
|
|
196
|
+
}
|
|
197
|
+
/** Runs the affected-rows statement, returning 0 on error rather than throwing into the loop. */
|
|
198
|
+
async affectedRows(db, sql, contextUser) {
|
|
199
|
+
try {
|
|
200
|
+
// Trailing SELECT is how the row count comes back as data across both dialects, rather
|
|
201
|
+
// than depending on a driver-specific rowsAffected field.
|
|
202
|
+
const rows = await db.ExecuteSQL(`${sql};\nSELECT @@ROWCOUNT AS ${db.QuoteIdentifier('AffectedRows')}`, undefined, undefined, contextUser);
|
|
203
|
+
return Number(rows?.[0]?.AffectedRows ?? 0);
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
LogError(`[TaskGraph] guarded write failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
literalOrNull(value) {
|
|
211
|
+
return value == null ? 'NULL' : `'${this.escape(value)}'`;
|
|
212
|
+
}
|
|
213
|
+
/** Single-quote escaping. Inputs here are UUIDs and JSON the dispatcher itself produced. */
|
|
214
|
+
escape(value) {
|
|
215
|
+
return value.replace(/'/g, "''");
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
//# sourceMappingURL=TaskClaimStore.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TaskClaimStore.js","sourceRoot":"","sources":["../src/TaskClaimStore.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { UserInfo } from '@memberjunction/core';
|
|
2
|
+
import { IShutdownable } from '@memberjunction/global';
|
|
3
|
+
import { ProviderFactory, TaskAgentRunner, TaskGraphDispatcherConfig, type TaskContinuationDeliverer, type TaskGraphObserver } from './types.js';
|
|
4
|
+
export declare class TaskGraphDispatcher implements IShutdownable {
|
|
5
|
+
private readonly providerFactory;
|
|
6
|
+
private readonly agentRunner;
|
|
7
|
+
private readonly contextUser;
|
|
8
|
+
/**
|
|
9
|
+
* Optional. Absent means a host that cannot post messages or start agent turns — a worker,
|
|
10
|
+
* a test. The dispatcher still records and logs every completion, so a graph's outcome is
|
|
11
|
+
* never lost; it simply is not announced.
|
|
12
|
+
*/
|
|
13
|
+
private readonly continuationDeliverer?;
|
|
14
|
+
/**
|
|
15
|
+
* Optional. Absent means nobody is watching — the dispatcher behaves identically, it just
|
|
16
|
+
* announces nothing.
|
|
17
|
+
*/
|
|
18
|
+
private readonly observer?;
|
|
19
|
+
private readonly config;
|
|
20
|
+
private readonly claims;
|
|
21
|
+
private readonly conditionEvaluator;
|
|
22
|
+
private running;
|
|
23
|
+
private pollTimer;
|
|
24
|
+
private reconcileTimer;
|
|
25
|
+
/** Tasks this instance is currently executing — bounds concurrency and drives heartbeats. */
|
|
26
|
+
private readonly inFlight;
|
|
27
|
+
/** Guards against a slow poll overlapping the next tick. */
|
|
28
|
+
private polling;
|
|
29
|
+
/** Graph → owning user, from the parent's durable metadata. Ownership never changes, so this never goes stale. */
|
|
30
|
+
private readonly ownerByParentID;
|
|
31
|
+
constructor(providerFactory: ProviderFactory, agentRunner: TaskAgentRunner, contextUser: UserInfo, config: Partial<TaskGraphDispatcherConfig> & Pick<TaskGraphDispatcherConfig, 'InstanceID'>,
|
|
32
|
+
/**
|
|
33
|
+
* Optional. Absent means a host that cannot post messages or start agent turns — a worker,
|
|
34
|
+
* a test. The dispatcher still records and logs every completion, so a graph's outcome is
|
|
35
|
+
* never lost; it simply is not announced.
|
|
36
|
+
*/
|
|
37
|
+
continuationDeliverer?: TaskContinuationDeliverer,
|
|
38
|
+
/**
|
|
39
|
+
* Optional. Absent means nobody is watching — the dispatcher behaves identically, it just
|
|
40
|
+
* announces nothing.
|
|
41
|
+
*/
|
|
42
|
+
observer?: TaskGraphObserver);
|
|
43
|
+
/**
|
|
44
|
+
* Announce something that happened, and never let the announcement matter.
|
|
45
|
+
*
|
|
46
|
+
* A frame is commentary on work, never a step of it, so an observer that throws must not be able
|
|
47
|
+
* to fail a task or stall a graph. Swallowing here rather than asking every implementation to be
|
|
48
|
+
* careful means one place enforces it.
|
|
49
|
+
*/
|
|
50
|
+
private emit;
|
|
51
|
+
/**
|
|
52
|
+
* Who a graph belongs to, memoized for the process's lifetime.
|
|
53
|
+
*
|
|
54
|
+
* Read from the parent's durable metadata rather than a column, because `Task.UserID` means
|
|
55
|
+
* "the person this task is waiting on" — setting it on a parent would make every graph look
|
|
56
|
+
* like a human task. Memoized because frames are emitted per step: without the cache, watching
|
|
57
|
+
* a run would cost one query per event, and observability that scales with work is the thing a
|
|
58
|
+
* push mechanism exists to avoid. Ownership never changes for a given graph, so the cache can
|
|
59
|
+
* never go stale.
|
|
60
|
+
*
|
|
61
|
+
* Skipped entirely when nobody is observing — the lookup exists only to address frames.
|
|
62
|
+
*/
|
|
63
|
+
private resolveOwner;
|
|
64
|
+
/**
|
|
65
|
+
* Begins dispatching.
|
|
66
|
+
*
|
|
67
|
+
* Runs reconciliation FIRST, before accepting any new work. On a restart this instance may be
|
|
68
|
+
* looking at tasks its own previous incarnation claimed and never released — reclaiming those
|
|
69
|
+
* up front is what turns a crash from "work stranded forever" into "work resumes".
|
|
70
|
+
*/
|
|
71
|
+
Start(): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* Stops accepting new work and waits for in-flight tasks to finish.
|
|
74
|
+
*
|
|
75
|
+
* Deliberately does NOT release claims on the way out: an abandoned claim expires on its own,
|
|
76
|
+
* and releasing eagerly would hand a still-running task to another instance mid-execution.
|
|
77
|
+
* Letting the TTL do it is the safer failure mode.
|
|
78
|
+
*/
|
|
79
|
+
Stop(): Promise<void>;
|
|
80
|
+
/** Name shown in the shutdown drain log. */
|
|
81
|
+
readonly ShutdownName = "TaskGraphDispatcher";
|
|
82
|
+
/** {@link IShutdownable} — idempotent by way of `Stop`'s `running` guard. */
|
|
83
|
+
Shutdown(): Promise<void>;
|
|
84
|
+
/**
|
|
85
|
+
* Reclaims expired claims and reports anomalies.
|
|
86
|
+
*
|
|
87
|
+
* Also enforces the two schema promises that previously had no enforcer anywhere: agent tasks
|
|
88
|
+
* left `In Progress` with no claim are surfaced loudly rather than silently corrected, since
|
|
89
|
+
* that shape indicates tampering or a bug and Record Changes already carries the audit trail.
|
|
90
|
+
*/
|
|
91
|
+
Reconcile(): Promise<void>;
|
|
92
|
+
/**
|
|
93
|
+
* One dispatch pass: find claimable work, claim what fits under the concurrency cap, execute.
|
|
94
|
+
*
|
|
95
|
+
* Overlap-guarded — a pass that runs long simply skips the next tick rather than stacking, which
|
|
96
|
+
* would otherwise let a slow database multiply in-flight work past the cap.
|
|
97
|
+
*/
|
|
98
|
+
private pollOnce;
|
|
99
|
+
/**
|
|
100
|
+
* Executes one claimed task on its own provider, heartbeating until it settles.
|
|
101
|
+
*
|
|
102
|
+
* A fresh provider per task is the point of `ProviderFactory`: parallel tasks must not share a
|
|
103
|
+
* transaction scope or entity instances, or one task's work becomes visible inside another's.
|
|
104
|
+
*/
|
|
105
|
+
private executeClaimed;
|
|
106
|
+
/**
|
|
107
|
+
* Applies failure propagation and parent rollup across every graph with active work.
|
|
108
|
+
*
|
|
109
|
+
* All four decisions — what is eligible, what must block, what the parent status is, whether the
|
|
110
|
+
* graph is wedged — are delegated to the pure algorithms, unchanged from Phase 1.
|
|
111
|
+
*/
|
|
112
|
+
private propagateAndRollup;
|
|
113
|
+
/**
|
|
114
|
+
* Runs the graph's continuation exactly once, now that it has settled.
|
|
115
|
+
*
|
|
116
|
+
* **Why the delivery marker is written before the side effect.** Delivery is at-least-once by
|
|
117
|
+
* nature: the process can die between "the graph is done" and "the user has been told". Marking
|
|
118
|
+
* first and acting second means the worst case is a *missed* notification that shows up in the
|
|
119
|
+
* task record as delivered — recoverable, visible, and inspectable. Marking after would make the
|
|
120
|
+
* worst case a *repeated* notification on every reconciliation sweep, forever, which is both
|
|
121
|
+
* user-visible noise and, for `reinvoke`, an unbounded agent-run loop. Given one of the two has
|
|
122
|
+
* to be chosen, the quiet failure is the safe one.
|
|
123
|
+
*
|
|
124
|
+
* The marker is written with a compare-and-swap read-back, so two instances reconciling the same
|
|
125
|
+
* completed graph produce one winner rather than two.
|
|
126
|
+
*/
|
|
127
|
+
private deliverContinuation;
|
|
128
|
+
/** Reads the parent's durable continuation metadata through the shared parser. */
|
|
129
|
+
private readParentMetadata;
|
|
130
|
+
/**
|
|
131
|
+
* Stamps the delivery marker and confirms this instance won the race.
|
|
132
|
+
*
|
|
133
|
+
* `MJ: Tasks` stays user-writable (D20), so a plain "read, decide, write" is not enough — the
|
|
134
|
+
* read-back is what makes a lost race observable instead of producing a duplicate delivery.
|
|
135
|
+
*/
|
|
136
|
+
private claimContinuation;
|
|
137
|
+
/** One line describing how the graph ended, for the completion log and message delivery. */
|
|
138
|
+
private buildContinuationSummary;
|
|
139
|
+
/**
|
|
140
|
+
* Tells the assignee that a human task is ready, exactly once.
|
|
141
|
+
*
|
|
142
|
+
* **Once** matters more than it looks: eligibility is recomputed on every poll, so a task parked
|
|
143
|
+
* on a person for three days would otherwise re-notify every five seconds until they acted. The
|
|
144
|
+
* marker is the task's own `ClaimedBy` — a human task has no executor to claim it, so the column
|
|
145
|
+
* is free, and reusing it means the "already notified" fact is as durable and as crash-safe as
|
|
146
|
+
* every other piece of graph state. A restart cannot resend.
|
|
147
|
+
*
|
|
148
|
+
* Best-effort by design. A notification that fails to send must not stop the graph or the poll
|
|
149
|
+
* loop; the task is still visible in the Tasks UI, so the work is discoverable even when the
|
|
150
|
+
* nudge does not arrive.
|
|
151
|
+
*/
|
|
152
|
+
private notifyHumanTaskReady;
|
|
153
|
+
/**
|
|
154
|
+
* Parent tasks that still have work to do.
|
|
155
|
+
*
|
|
156
|
+
* `BypassCache` for the reason the caching guide names explicitly: **the claim protocol mutates
|
|
157
|
+
* these rows through direct SQL**, because the CAS guarantee IS the database's atomicity and a
|
|
158
|
+
* `BaseEntity.Save()` cannot express a guarded UPDATE. Direct DML fires no invalidation event,
|
|
159
|
+
* so a cached read of this query is stale the instant any task is claimed or completed — and
|
|
160
|
+
* the dispatcher would then be reading its own work queue through a cache its own writes never
|
|
161
|
+
* invalidate. Left cached, a completed task keeps reading as `In Progress` and the graph never
|
|
162
|
+
* rolls up: submitted work simply never settles.
|
|
163
|
+
*/
|
|
164
|
+
private findActiveGraphIDs;
|
|
165
|
+
/**
|
|
166
|
+
* Tasks eligible to claim right now, across all active graphs.
|
|
167
|
+
*
|
|
168
|
+
* Eligibility is decided by the pure algorithm rather than by SQL: expressing "all prerequisites
|
|
169
|
+
* complete" as a query is possible but would be a second, independently-maintained definition of
|
|
170
|
+
* the same rule, free to drift from the one the in-run executor uses.
|
|
171
|
+
*/
|
|
172
|
+
private findClaimableTasks;
|
|
173
|
+
/** Loads a graph's children and edges in the shapes both the algorithms and mutation need. */
|
|
174
|
+
private loadGraphState;
|
|
175
|
+
/**
|
|
176
|
+
* Decides whether a conditional dependency edge is live.
|
|
177
|
+
*
|
|
178
|
+
* The condition sees the upstream task's outcome — its status and parsed output — which is the
|
|
179
|
+
* only information a runtime graph has to branch on. Returns `'drop'` only on a definite false;
|
|
180
|
+
* an unevaluable condition keeps the edge for the reason stated at the call site.
|
|
181
|
+
*/
|
|
182
|
+
private evaluateEdgeCondition;
|
|
183
|
+
/** Parsed `OutputPayload` of each completed dependency, keyed by that task's ID. */
|
|
184
|
+
private loadDependencyOutputs;
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=TaskGraphDispatcher.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TaskGraphDispatcher.d.ts","sourceRoot":"","sources":["../src/TaskGraphDispatcher.ts"],"names":[],"mappings":"AA6BA,OAAO,EAAmD,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AACjG,OAAO,EAAE,aAAa,EAAoB,MAAM,wBAAwB,CAAC;AAkBzE,OAAO,EAEH,eAAe,EACf,eAAe,EACf,yBAAyB,EACzB,KAAK,yBAAyB,EAG9B,KAAK,iBAAiB,EACzB,MAAM,SAAS,CAAC;AAcjB,qBAAa,mBAAoB,YAAW,aAAa;IAiBjD,OAAO,CAAC,QAAQ,CAAC,eAAe;IAChC,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,WAAW;IAE5B;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAC;IACvC;;;OAGG;IACH,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC;IA9B9B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAA4B;IACnD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiB;IACxC,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAA+B;IAElE,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,SAAS,CAA+C;IAChE,OAAO,CAAC,cAAc,CAA+C;IACrE,6FAA6F;IAC7F,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAqB;IAC9C,4DAA4D;IAC5D,OAAO,CAAC,OAAO,CAAS;IAExB,kHAAkH;IAClH,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAoC;gBAG/C,eAAe,EAAE,eAAe,EAChC,WAAW,EAAE,eAAe,EAC5B,WAAW,EAAE,QAAQ,EACtC,MAAM,EAAE,OAAO,CAAC,yBAAyB,CAAC,GAAG,IAAI,CAAC,yBAAyB,EAAE,YAAY,CAAC;IAC1F;;;;OAIG;IACc,qBAAqB,CAAC,EAAE,yBAAyB;IAClE;;;OAGG;IACc,QAAQ,CAAC,EAAE,iBAAiB;IAOjD;;;;;;OAMG;IACH,OAAO,CAAC,IAAI;IASZ;;;;;;;;;;;OAWG;YACW,YAAY;IAmB1B;;;;;;OAMG;IACU,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAmBnC;;;;;;OAMG;IACU,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;IAelC,4CAA4C;IAC5C,SAAgB,YAAY,yBAAyB;IAErD,6EAA6E;IAChE,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAItC;;;;;;OAMG;IACU,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC;IAiBvC;;;;;OAKG;YACW,QAAQ;IA+BtB;;;;;OAKG;YACW,cAAc;IAsF5B;;;;;OAKG;YACW,kBAAkB;IA8DhC;;;;;;;;;;;;;OAaG;YACW,mBAAmB;IAgEjC,kFAAkF;IAClF,OAAO,CAAC,kBAAkB;IAI1B;;;;;OAKG;YACW,iBAAiB;IAmB/B,4FAA4F;IAC5F,OAAO,CAAC,wBAAwB;IAOhC;;;;;;;;;;;;OAYG;YACW,oBAAoB;IAsClC;;;;;;;;;;OAUG;YACW,kBAAkB;IA2ChC;;;;;;OAMG;YACW,kBAAkB;IA4BhC,8FAA8F;YAChF,cAAc;IA8D5B;;;;;;OAMG;IACH,OAAO,CAAC,qBAAqB;IAgC7B,oFAAoF;YACtE,qBAAqB;CAkBtC"}
|