@avakado.ai/schemas 1.1.18 → 1.1.20
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/package.json +1 -1
- package/src/enums/index.js +6 -4
- package/src/models/Campaign.js +4 -0
- package/src/models/Conversations.js +3 -12
- package/src/models/Leads.js +5 -2
- package/src/models/Workflow.js +54 -1
- package/src/models/WorkflowLogs.js +39 -16
- package/src/statusTrigger.js +217 -0
- package/src/workflowRunner.js +190 -0
package/package.json
CHANGED
package/src/enums/index.js
CHANGED
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
// missing from a validator, a partial index filtering on a status list that had fallen behind.
|
|
4
4
|
// Anything that validates or filters on these values must read them from here.
|
|
5
5
|
|
|
6
|
+
export const LEAD_STATUS = ["new", "contacted", "qualified", "converted", "lost", "spam"];
|
|
7
|
+
export const CONVERSATION_STATUS = ["open", "pending", "snoozed", "completed", "closed", "archived", "spam"];
|
|
8
|
+
export const CAMPAIGN_STATUS = ["pending", "active", "completed", "failed", "partial"];
|
|
9
|
+
export const TASK_STATUS = ["pending", "in-progress", "completed", "failed", "skipped"];
|
|
10
|
+
export const WORKFLOW_TRIGGERS = [...LEAD_STATUS.map(status => `lead_${status}`), ...CONVERSATION_STATUS.map(status => `conversation_${status}`), ...CAMPAIGN_STATUS.map(status => `campaign_${status}`), ...TASK_STATUS.map(status => `task_${status}`)];
|
|
11
|
+
export const CONVERSATION_PRIORITY = ["low", "normal", "high", "urgent"];
|
|
6
12
|
export const SUBSCRIPTION_STATUS = [
|
|
7
13
|
"created",
|
|
8
14
|
"pending_payment",
|
|
@@ -55,11 +61,7 @@ export const LEDGER_CATEGORY = {
|
|
|
55
61
|
THIRD_PARTY: "third_party",
|
|
56
62
|
};
|
|
57
63
|
|
|
58
|
-
export const CONVERSATION_STATUS = ["open", "pending", "snoozed", "completed", "closed", "archived", "spam"];
|
|
59
|
-
export const CONVERSATION_PRIORITY = ["low", "normal", "high", "urgent"];
|
|
60
64
|
|
|
61
|
-
export const CAMPAIGN_STATUS = ["pending", "active", "completed", "failed", "partial"];
|
|
62
|
-
export const TASK_STATUS = ["pending", "in-progress", "completed", "failed", "skipped"];
|
|
63
65
|
export const TASK_TYPES = ["quick", "webhook"];
|
|
64
66
|
export const TASK_REFERENCE_TYPES = ["Message", "CallSession"];
|
|
65
67
|
|
package/src/models/Campaign.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Schema } from "mongoose";
|
|
2
2
|
import { defineModel } from "../runtime.js";
|
|
3
3
|
import { CAMPAIGN_STATUS, TASK_REFERENCE_TYPES, TASK_STATUS, TASK_TYPES } from "../enums/index.js";
|
|
4
|
+
import { attachStatusChangeTrigger } from "../statusTrigger.js";
|
|
4
5
|
|
|
5
6
|
const TaskSchema = new Schema({
|
|
6
7
|
business: { type: Schema.Types.ObjectId, ref: 'Businesses', required: true },
|
|
@@ -62,6 +63,9 @@ CampaignSchema.methods.updateTimeLine = async function (timeLine, currentStatus)
|
|
|
62
63
|
return this.save();
|
|
63
64
|
};
|
|
64
65
|
|
|
66
|
+
attachStatusChangeTrigger(TaskSchema, "task");
|
|
67
|
+
attachStatusChangeTrigger(CampaignSchema, "campaign");
|
|
68
|
+
|
|
65
69
|
export { CampaignSchema, TaskSchema };
|
|
66
70
|
export const Campaign = defineModel('Campaign', CampaignSchema, 'Campaign');
|
|
67
71
|
export const Task = defineModel('Task', TaskSchema, 'Tasks');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Schema } from "mongoose";
|
|
2
2
|
import { defineModel, requireDep } from "../runtime.js";
|
|
3
3
|
import { CONVERSATION_PRIORITY, CONVERSATION_STATUS } from "../enums/index.js";
|
|
4
|
+
import { attachStatusChangeTrigger } from "../statusTrigger.js";
|
|
4
5
|
const ConversationSchema = new Schema({
|
|
5
6
|
agent: { type: Schema.Types.ObjectId, ref: 'Agent' }, // the AI agent
|
|
6
7
|
business: { type: Schema.Types.ObjectId, ref: 'Businesses' },
|
|
@@ -76,20 +77,10 @@ ConversationSchema.methods.updateStatus = async function (status) {
|
|
|
76
77
|
acks: -1
|
|
77
78
|
});
|
|
78
79
|
}
|
|
79
|
-
|
|
80
|
-
// if (businessId) {
|
|
81
|
-
// const trigger = await Trigger.findOne({ business: businessId, name: "conversationStatusUpdated", type: status });
|
|
82
|
-
// for (const workflow of trigger?.workflows ?? []) {
|
|
83
|
-
// await sendKafkaMessage({
|
|
84
|
-
// topic: 'workflow-execution-trigger',
|
|
85
|
-
// message: { key: workflow.toString(), value: JSON.stringify({ conversationId }) },
|
|
86
|
-
// acks: -1
|
|
87
|
-
// });
|
|
88
|
-
// }
|
|
89
|
-
// }
|
|
90
|
-
|
|
91
80
|
return saved;
|
|
92
81
|
};
|
|
93
82
|
|
|
83
|
+
attachStatusChangeTrigger(ConversationSchema, "conversation");
|
|
84
|
+
|
|
94
85
|
export { ConversationSchema };
|
|
95
86
|
export const Conversation = defineModel('Conversation', ConversationSchema, "Conversations");
|
package/src/models/Leads.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Schema } from "mongoose";
|
|
2
2
|
import { defineModel } from "../runtime.js";
|
|
3
|
-
|
|
3
|
+
import { LEAD_STATUS } from "../enums/index.js";
|
|
4
|
+
import { attachStatusChangeTrigger } from "../statusTrigger.js";
|
|
4
5
|
const platformEntrySchema = new Schema({
|
|
5
6
|
platform: { type: String, required: true }, // Phone,'Twitter', 'Instagram', 'Whatsapp', etc.
|
|
6
7
|
handle: { type: String, trim: true },
|
|
@@ -49,7 +50,7 @@ const LeadSchema = new Schema({
|
|
|
49
50
|
tags: [String],
|
|
50
51
|
//evaluation
|
|
51
52
|
leadScore: { type: Number, default: 0 },
|
|
52
|
-
status: { type: String, enum:
|
|
53
|
+
status: { type: String, enum: LEAD_STATUS, default: 'new' },
|
|
53
54
|
notes: { type: [String], trim: true },
|
|
54
55
|
// operations
|
|
55
56
|
lastInteractedAt: Date,
|
|
@@ -80,5 +81,7 @@ const LeadTemplateSchema = new Schema({
|
|
|
80
81
|
isActive: { type: Boolean, default: true }
|
|
81
82
|
}, { timestamps: true });
|
|
82
83
|
|
|
84
|
+
attachStatusChangeTrigger(LeadSchema, "lead");
|
|
85
|
+
|
|
83
86
|
export const Lead = defineModel('Lead', LeadSchema, 'Leads');
|
|
84
87
|
export const LeadTemplate = defineModel('LeadTemplate', LeadTemplateSchema, 'LeadTemplates');
|
package/src/models/Workflow.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { Schema } from "mongoose";
|
|
2
3
|
import { defineModel } from "../runtime.js";
|
|
3
4
|
import { Task_Tree } from "./Action.js";
|
|
5
|
+
import { WORKFLOW_TRIGGERS } from "../enums/index.js";
|
|
6
|
+
import { WorkflowLogs } from "./WorkflowLogs.js";
|
|
7
|
+
import { runTaskTree, stored } from "../workflowRunner.js";
|
|
4
8
|
const WorkflowSchema = new Schema({
|
|
5
9
|
name: { type: String, default: "Untitled Workflow" },
|
|
6
|
-
trigger: { type: String, required: true },
|
|
10
|
+
trigger: { type: String, required: true, enum: WORKFLOW_TRIGGERS },
|
|
7
11
|
business: { type: Schema.Types.ObjectId, ref: "Businesses" },
|
|
8
12
|
status: { type: String, enum: ["active", "disabled"], default: "active" },
|
|
9
13
|
task: Task_Tree,
|
|
@@ -17,6 +21,55 @@ WorkflowSchema.methods.updateStatus = function (status) {
|
|
|
17
21
|
this.status = status;
|
|
18
22
|
return this.save();
|
|
19
23
|
};
|
|
24
|
+
WorkflowSchema.methods.execute = async function (payload, meta = {}) {
|
|
25
|
+
if (this.status === "disabled") return { skipped: true };
|
|
26
|
+
const businessId = String(this.business?._id ?? this.business ?? "");
|
|
27
|
+
const executionId = randomUUID();
|
|
28
|
+
const startedAt = new Date();
|
|
29
|
+
const log = await WorkflowLogs.create({
|
|
30
|
+
workflow: this._id,
|
|
31
|
+
business: this.business?._id ?? this.business,
|
|
32
|
+
executionId,
|
|
33
|
+
eventId: meta.eventId,
|
|
34
|
+
name: this.name,
|
|
35
|
+
trigger: this.trigger,
|
|
36
|
+
status: "running",
|
|
37
|
+
summary: `Workflow "${this.name}" started for ${this.trigger}.`,
|
|
38
|
+
input: stored(payload),
|
|
39
|
+
steps: [],
|
|
40
|
+
startedAt,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const record = async (step) => {
|
|
44
|
+
log.steps.push(step);
|
|
45
|
+
log.summary = [`Workflow "${this.name}" ran for ${this.trigger}.`, ...log.steps.map((item) => item.summary)].join("\n");
|
|
46
|
+
await log.save();
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const outputs = await runTaskTree(this.task, {
|
|
51
|
+
runtime: payload ?? {},
|
|
52
|
+
trigger: this.trigger,
|
|
53
|
+
businessId,
|
|
54
|
+
onStep: record,
|
|
55
|
+
});
|
|
56
|
+
log.status = "completed";
|
|
57
|
+
log.outputs = stored(outputs);
|
|
58
|
+
log.finishedAt = new Date();
|
|
59
|
+
log.summary = [`Workflow "${this.name}" completed for ${this.trigger}.`, ...log.steps.map((item) => item.summary)].join("\n");
|
|
60
|
+
await log.save();
|
|
61
|
+
return { executionId, outputs };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
log.status = "failed";
|
|
64
|
+
log.error = { message: error?.message || String(error), stack: error?.stack };
|
|
65
|
+
log.outputs = stored(error.workflowOutputs ?? []);
|
|
66
|
+
log.finishedAt = new Date();
|
|
67
|
+
log.summary = [`Workflow "${this.name}" failed for ${this.trigger}.`, ...log.steps.map((item) => item.summary)].filter(Boolean).join("\n");
|
|
68
|
+
if (!log.steps.some((step) => step.status === "failed")) log.summary += `\n${error?.message || error}`;
|
|
69
|
+
await log.save();
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
20
73
|
// WorkflowSchema.methods.getNextNode = function ({ currentNode = "", outputPortsOfCurentNode = [] } = {}) {
|
|
21
74
|
// const nextNodes = [];
|
|
22
75
|
// if (!currentNode) return [Object.keys(this.nodes).find((ele) => this.nodes[ele].type === "trigger")];
|
|
@@ -1,22 +1,45 @@
|
|
|
1
1
|
import { Schema } from "mongoose";
|
|
2
2
|
import { defineModel } from "../runtime.js";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
conversationId: String,
|
|
8
|
-
},
|
|
9
|
-
executionId: String,
|
|
4
|
+
// One document per workflow run. `steps` is the order the task tree actually ran,
|
|
5
|
+
// including the input and output of each step, so a person can read what happened.
|
|
6
|
+
const workflowStepSchema = new Schema({
|
|
10
7
|
nodeId: String,
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
8
|
+
label: String,
|
|
9
|
+
type: { type: String, enum: ["task", "conditional"] },
|
|
10
|
+
status: { type: String, enum: ["completed", "failed"], required: true },
|
|
11
|
+
summary: String,
|
|
12
|
+
input: Schema.Types.Mixed,
|
|
13
|
+
output: Schema.Types.Mixed,
|
|
14
|
+
error: { message: String, stack: String },
|
|
15
|
+
startedAt: Date,
|
|
16
|
+
finishedAt: Date,
|
|
17
|
+
}, { _id: false });
|
|
18
|
+
|
|
19
|
+
const WorkflowLogsSchema = new Schema({
|
|
20
|
+
workflow: { type: Schema.Types.ObjectId, ref: "Workflow", required: true },
|
|
21
|
+
business: { type: Schema.Types.ObjectId, ref: "Businesses" },
|
|
22
|
+
executionId: { type: String, required: true },
|
|
23
|
+
eventId: String,
|
|
24
|
+
name: String,
|
|
25
|
+
trigger: String,
|
|
26
|
+
status: { type: String, enum: ["running", "completed", "failed"], default: "running" },
|
|
27
|
+
summary: String,
|
|
28
|
+
input: Schema.Types.Mixed,
|
|
29
|
+
outputs: Schema.Types.Mixed,
|
|
30
|
+
error: { message: String, stack: String },
|
|
31
|
+
steps: [workflowStepSchema],
|
|
32
|
+
startedAt: Date,
|
|
33
|
+
finishedAt: Date,
|
|
17
34
|
}, {
|
|
18
|
-
timestamps: true
|
|
19
|
-
}
|
|
20
|
-
|
|
35
|
+
timestamps: true,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
WorkflowLogsSchema.index({ executionId: 1 }, { unique: true });
|
|
39
|
+
WorkflowLogsSchema.index({ workflow: 1, createdAt: -1 });
|
|
40
|
+
WorkflowLogsSchema.index({ business: 1, createdAt: -1 });
|
|
41
|
+
WorkflowLogsSchema.index({ trigger: 1, createdAt: -1 });
|
|
42
|
+
WorkflowLogsSchema.index({ status: 1, createdAt: -1 });
|
|
21
43
|
|
|
22
|
-
export
|
|
44
|
+
export { WorkflowLogsSchema };
|
|
45
|
+
export const WorkflowLogs = defineModel("WorkflowLogs", WorkflowLogsSchema, "WorkflowLogs");
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { CAMPAIGN_STATUS, CONVERSATION_STATUS, LEAD_STATUS, TASK_STATUS } from "./enums/index.js";
|
|
3
|
+
import { requireDep } from "./runtime.js";
|
|
4
|
+
|
|
5
|
+
// Status is not written in one place. Conversations go through updateStatus, but also
|
|
6
|
+
// findOneAndUpdate. Leads are created and patched with findOneAndUpdate / findByIdAndUpdate.
|
|
7
|
+
// Campaigns and tasks change status inside updateTimeLine, and tasks are also insertMany'd
|
|
8
|
+
// and updateMany'd to "skipped". These hooks run after the write, so a status that fails
|
|
9
|
+
// validation never publishes a trigger for a change that did not persist.
|
|
10
|
+
|
|
11
|
+
export const WORKFLOW_TRIGGER_TOPIC = "workflow-execution-trigger";
|
|
12
|
+
|
|
13
|
+
const STATUS_BY_ENTITY = {
|
|
14
|
+
lead: new Set(LEAD_STATUS),
|
|
15
|
+
conversation: new Set(CONVERSATION_STATUS),
|
|
16
|
+
campaign: new Set(CAMPAIGN_STATUS),
|
|
17
|
+
task: new Set(TASK_STATUS),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const UPDATE_HOOKS = ["findOneAndUpdate", "updateOne", "updateMany", "replaceOne", "findOneAndReplace"];
|
|
21
|
+
const pendingWrites = new WeakMap();
|
|
22
|
+
const BATCH_SIZE = 50;
|
|
23
|
+
|
|
24
|
+
function allowed(entity, status) {
|
|
25
|
+
return typeof status === "string" && STATUS_BY_ENTITY[entity]?.has(status);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function businessIdOf(doc) {
|
|
29
|
+
const business = doc?.business;
|
|
30
|
+
if (business == null || business === "") return "";
|
|
31
|
+
if (typeof business === "object" && business._id != null) return String(business._id);
|
|
32
|
+
return String(business);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function serializeDocument(doc) {
|
|
36
|
+
if (doc == null) return null;
|
|
37
|
+
try {
|
|
38
|
+
const plain = typeof doc.toObject === "function" ? doc.toObject({ virtuals: false }) : doc;
|
|
39
|
+
const seen = new WeakSet();
|
|
40
|
+
return JSON.parse(JSON.stringify(plain, (_key, value) => {
|
|
41
|
+
if (typeof value === "bigint") return value.toString();
|
|
42
|
+
if (typeof value === "object" && value !== null) {
|
|
43
|
+
if (seen.has(value)) return undefined;
|
|
44
|
+
seen.add(value);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}));
|
|
48
|
+
} catch {
|
|
49
|
+
return {
|
|
50
|
+
_id: doc._id != null ? String(doc._id) : null,
|
|
51
|
+
status: doc.status ?? null,
|
|
52
|
+
business: businessIdOf(doc) || null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readNextStatus(entity, update) {
|
|
58
|
+
if (update == null || typeof update !== "object") return null;
|
|
59
|
+
let status;
|
|
60
|
+
// Timestamps middleware adds `$set.updatedAt` without moving the caller's fields,
|
|
61
|
+
// so a plain update arrives as `{ status, $set: { updatedAt } }`.
|
|
62
|
+
if (Array.isArray(update)) {
|
|
63
|
+
for (const stage of update) {
|
|
64
|
+
if (typeof stage?.$set?.status === "string") status = stage.$set.status;
|
|
65
|
+
}
|
|
66
|
+
} else if (typeof update.$set?.status === "string") {
|
|
67
|
+
status = update.$set.status;
|
|
68
|
+
} else if (typeof update.status === "string") {
|
|
69
|
+
status = update.status;
|
|
70
|
+
}
|
|
71
|
+
return allowed(entity, status) ? status : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function statusTriggerMessage({ entity, status, previousStatus, businessId, document }) {
|
|
75
|
+
if (!allowed(entity, status) || previousStatus === status) return null;
|
|
76
|
+
const eventId = randomUUID();
|
|
77
|
+
const entityId = document?._id != null ? String(document._id) : "";
|
|
78
|
+
const resolvedBusinessId = businessId ? String(businessId) : businessIdOf(document);
|
|
79
|
+
const trigger = `${entity}_${status}`;
|
|
80
|
+
const body = {
|
|
81
|
+
eventId,
|
|
82
|
+
businessId: resolvedBusinessId,
|
|
83
|
+
trigger,
|
|
84
|
+
payload: {
|
|
85
|
+
entity,
|
|
86
|
+
entityId,
|
|
87
|
+
status,
|
|
88
|
+
previousStatus: previousStatus ?? null,
|
|
89
|
+
changedAt: new Date().toISOString(),
|
|
90
|
+
document: serializeDocument(document),
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
return {
|
|
94
|
+
key: resolvedBusinessId || entityId || eventId,
|
|
95
|
+
value: JSON.stringify(body),
|
|
96
|
+
headers: {
|
|
97
|
+
eventId,
|
|
98
|
+
businessId: resolvedBusinessId,
|
|
99
|
+
trigger,
|
|
100
|
+
entity,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function publishStatusChanges(changes) {
|
|
106
|
+
const messages = changes.map(statusTriggerMessage).filter(Boolean);
|
|
107
|
+
if (!messages.length) return;
|
|
108
|
+
const sendKafkaMessage = requireDep("sendKafkaMessage", "workflow status trigger");
|
|
109
|
+
for (let i = 0; i < messages.length; i += BATCH_SIZE) {
|
|
110
|
+
const batch = messages.slice(i, i + BATCH_SIZE);
|
|
111
|
+
try {
|
|
112
|
+
await sendKafkaMessage({ topic: WORKFLOW_TRIGGER_TOPIC, messages: batch, acks: -1 });
|
|
113
|
+
} catch (error) {
|
|
114
|
+
console.error(
|
|
115
|
+
`@avakado.ai/schemas: failed to publish ${batch.length} workflow status trigger(s) on ${WORKFLOW_TRIGGER_TOPIC}`,
|
|
116
|
+
error?.message || error
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function writeApplied(result) {
|
|
123
|
+
if (result == null || typeof result !== "object") return false;
|
|
124
|
+
if ("matchedCount" in result || "modifiedCount" in result || "upsertedCount" in result) {
|
|
125
|
+
return (result.matchedCount || 0) > 0 || (result.modifiedCount || 0) > 0 || (result.upsertedCount || 0) > 0;
|
|
126
|
+
}
|
|
127
|
+
return result._id != null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function attachStatusChangeTrigger(schema, entity) {
|
|
131
|
+
schema.post("init", function recordLoadedStatus() {
|
|
132
|
+
this.$locals.loadedStatus = this.status;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
schema.pre("save", function captureStatusChange() {
|
|
136
|
+
const status = this.status;
|
|
137
|
+
if (this.isNew) {
|
|
138
|
+
this.$locals.statusEmit = allowed(entity, status) ? { status, previousStatus: null } : null;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!this.isModified("status") || !allowed(entity, status)) {
|
|
142
|
+
this.$locals.statusEmit = null;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const previousStatus = Object.prototype.hasOwnProperty.call(this.$locals, "loadedStatus")
|
|
146
|
+
? this.$locals.loadedStatus
|
|
147
|
+
: null;
|
|
148
|
+
this.$locals.statusEmit = previousStatus === status ? null : { status, previousStatus };
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
schema.post("save", async function publishSavedStatus() {
|
|
152
|
+
const emit = this.$locals.statusEmit;
|
|
153
|
+
this.$locals.loadedStatus = this.status;
|
|
154
|
+
this.$locals.statusEmit = null;
|
|
155
|
+
if (!emit) return;
|
|
156
|
+
await publishStatusChanges([{
|
|
157
|
+
entity,
|
|
158
|
+
status: emit.status,
|
|
159
|
+
previousStatus: emit.previousStatus,
|
|
160
|
+
businessId: businessIdOf(this),
|
|
161
|
+
document: this,
|
|
162
|
+
}]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
schema.pre(UPDATE_HOOKS, async function captureQueryStatusChange() {
|
|
166
|
+
const status = readNextStatus(entity, this.getUpdate());
|
|
167
|
+
if (!status) return;
|
|
168
|
+
const session = this.getOptions()?.session;
|
|
169
|
+
let finder = this.model.find(this.getFilter()).select("_id status");
|
|
170
|
+
if (session) finder = finder.session(session);
|
|
171
|
+
const rows = await finder.lean();
|
|
172
|
+
const changed = rows.filter((row) => row.status !== status);
|
|
173
|
+
if (!changed.length) return;
|
|
174
|
+
pendingWrites.set(this, {
|
|
175
|
+
status,
|
|
176
|
+
previousById: new Map(changed.map((row) => [String(row._id), row.status ?? null])),
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
schema.post(UPDATE_HOOKS, async function publishQueryStatusChange(result) {
|
|
181
|
+
const pending = pendingWrites.get(this);
|
|
182
|
+
pendingWrites.delete(this);
|
|
183
|
+
if (!pending || !writeApplied(result)) return;
|
|
184
|
+
|
|
185
|
+
let docs = [];
|
|
186
|
+
if (result?._id && pending.previousById.has(String(result._id)) && result.status === pending.status) {
|
|
187
|
+
docs = [result];
|
|
188
|
+
} else {
|
|
189
|
+
const session = this.getOptions()?.session;
|
|
190
|
+
let finder = this.model.find({
|
|
191
|
+
_id: { $in: [...pending.previousById.keys()] },
|
|
192
|
+
status: pending.status,
|
|
193
|
+
});
|
|
194
|
+
if (session) finder = finder.session(session);
|
|
195
|
+
docs = await finder.lean();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
await publishStatusChanges(docs.map((doc) => ({
|
|
199
|
+
entity,
|
|
200
|
+
status: doc.status,
|
|
201
|
+
previousStatus: pending.previousById.get(String(doc._id)) ?? null,
|
|
202
|
+
businessId: businessIdOf(doc),
|
|
203
|
+
document: doc,
|
|
204
|
+
})));
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
schema.post("insertMany", async function publishInsertedStatus(docs) {
|
|
208
|
+
if (!Array.isArray(docs) || !docs.length) return;
|
|
209
|
+
await publishStatusChanges(docs.map((doc) => ({
|
|
210
|
+
entity,
|
|
211
|
+
status: doc?.status,
|
|
212
|
+
previousStatus: null,
|
|
213
|
+
businessId: businessIdOf(doc),
|
|
214
|
+
document: doc,
|
|
215
|
+
})));
|
|
216
|
+
});
|
|
217
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Walks a workflow task the same way tool_utils.executeTool does: each node reads
|
|
2
|
+
// `context.runtime`, `context.preset`, and `context.outputs`, then a task runs its
|
|
3
|
+
// compiled instructions. Conditionals take the first child when the mapped value is
|
|
4
|
+
// truthy and the second child otherwise.
|
|
5
|
+
|
|
6
|
+
const AsyncFunction = Object.getPrototypeOf(async function () { }).constructor;
|
|
7
|
+
|
|
8
|
+
export function plainValue(value) {
|
|
9
|
+
if (value == null) return value;
|
|
10
|
+
if (typeof value.toObject === "function") return value.toObject({ virtuals: false });
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getTreeMap(tree) {
|
|
15
|
+
const plain = plainValue(tree) ?? {};
|
|
16
|
+
const nodes = new Map((plain.nodes ?? []).map((node) => [node.id, node]));
|
|
17
|
+
return { rootId: plain.rootId, rootKey: plain.rootKey, nodes };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function evaluateExpression(expression, context) {
|
|
21
|
+
return new Function("context", `return (${expression});`)(context);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// tool_utils.evaluateToolInput
|
|
25
|
+
export function evaluateMapper(mapper, context) {
|
|
26
|
+
const treeMap = getTreeMap(mapper);
|
|
27
|
+
const evaluate = (node) => {
|
|
28
|
+
if (!node) return undefined;
|
|
29
|
+
const expression = node.core?.mapper?.expression;
|
|
30
|
+
if (expression) return evaluateExpression(expression, context);
|
|
31
|
+
switch (node.core?.flattenedSchema?.type) {
|
|
32
|
+
case "object":
|
|
33
|
+
return Object.fromEntries((node.children ?? []).map((child) => {
|
|
34
|
+
const childNode = treeMap.nodes.get(child.id);
|
|
35
|
+
return [child.key, childNode ? evaluate(childNode) : undefined];
|
|
36
|
+
}));
|
|
37
|
+
case "array":
|
|
38
|
+
return (node.children ?? []).map((child) => {
|
|
39
|
+
const childNode = treeMap.nodes.get(child.id);
|
|
40
|
+
return childNode ? evaluate(childNode) : undefined;
|
|
41
|
+
});
|
|
42
|
+
default:
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const root = treeMap.nodes.get(treeMap.rootId);
|
|
47
|
+
return root ? evaluate(root) : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function nodeInput(node, context) {
|
|
51
|
+
const compiled = node.core?.input?.compiledMapper;
|
|
52
|
+
if (typeof compiled === "string" && compiled.trim()) return evaluateExpression(compiled, context);
|
|
53
|
+
if (node.core?.input?.mapper) return evaluateMapper(node.core.input.mapper, context);
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function nodeLabel(node) {
|
|
58
|
+
return node?.meta?.label || node?.core?.meta?.label || node?.id || "Untitled step";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function runInstructions(node, input) {
|
|
62
|
+
const instructions = node.core?.coreExecution?.compiledInstructions;
|
|
63
|
+
if (typeof instructions !== "string" || !instructions.trim()) return undefined;
|
|
64
|
+
return new AsyncFunction("input", instructions)(input);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function stored(value) {
|
|
68
|
+
if (value instanceof Error) return { message: value.message, stack: value.stack };
|
|
69
|
+
const seen = new WeakSet();
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(JSON.stringify(value, (_key, item) => {
|
|
72
|
+
if (typeof item === "bigint") return item.toString();
|
|
73
|
+
if (typeof item === "object" && item !== null) {
|
|
74
|
+
if (seen.has(item)) return undefined;
|
|
75
|
+
seen.add(item);
|
|
76
|
+
}
|
|
77
|
+
return item;
|
|
78
|
+
}));
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function childNode(treeMap, child) {
|
|
85
|
+
if (!child?.id) return undefined;
|
|
86
|
+
const node = treeMap.nodes.get(child.id);
|
|
87
|
+
if (!node) throw new Error(`Invalid tree: referenced node "${child.id}" does not exist`);
|
|
88
|
+
return node;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Runs the task tree from its root. `onStep` is awaited with each step once that
|
|
93
|
+
* step has finished (completed or failed) so the caller can persist it.
|
|
94
|
+
* A failed step rejects after `onStep` returns.
|
|
95
|
+
*/
|
|
96
|
+
export async function runTaskTree(task, { runtime, trigger, businessId, onStep }) {
|
|
97
|
+
const treeMap = getTreeMap(task);
|
|
98
|
+
const outputs = [];
|
|
99
|
+
const root = treeMap.nodes.get(treeMap.rootId);
|
|
100
|
+
if (!root) throw new Error("Workflow task has no root node");
|
|
101
|
+
|
|
102
|
+
const execute = async (node) => {
|
|
103
|
+
const startedAt = new Date();
|
|
104
|
+
const label = nodeLabel(node);
|
|
105
|
+
const context = {
|
|
106
|
+
runtime: runtime ?? {},
|
|
107
|
+
preset: node.core?.input?.preset ?? {},
|
|
108
|
+
outputs,
|
|
109
|
+
trigger,
|
|
110
|
+
businessId,
|
|
111
|
+
payload: runtime ?? {},
|
|
112
|
+
};
|
|
113
|
+
let completed;
|
|
114
|
+
try {
|
|
115
|
+
if (node.core?.type === "task") {
|
|
116
|
+
if ((node.children ?? []).length > 1) {
|
|
117
|
+
throw new Error(`Task "${label}" has multiple outgoing edges`);
|
|
118
|
+
}
|
|
119
|
+
const input = nodeInput(node, context);
|
|
120
|
+
const output = await runInstructions(node, input);
|
|
121
|
+
outputs.push(output);
|
|
122
|
+
const next = (node.children ?? []).length ? childNode(treeMap, node.children[0]) : undefined;
|
|
123
|
+
completed = {
|
|
124
|
+
step: {
|
|
125
|
+
nodeId: node.id,
|
|
126
|
+
label,
|
|
127
|
+
type: "task",
|
|
128
|
+
status: "completed",
|
|
129
|
+
input: stored(input),
|
|
130
|
+
output: stored(output),
|
|
131
|
+
summary: `Completed "${label}".`,
|
|
132
|
+
startedAt,
|
|
133
|
+
finishedAt: new Date(),
|
|
134
|
+
},
|
|
135
|
+
next,
|
|
136
|
+
};
|
|
137
|
+
} else if (node.core?.type === "conditional") {
|
|
138
|
+
if ((node.children ?? []).length > 2) {
|
|
139
|
+
throw new Error(`Condition "${label}" cannot have more than 2 outgoing edges`);
|
|
140
|
+
}
|
|
141
|
+
const condition = nodeInput(node, context);
|
|
142
|
+
outputs.push(condition);
|
|
143
|
+
const nextChild = condition ? node.children?.[0] : node.children?.[1];
|
|
144
|
+
const next = nextChild ? childNode(treeMap, nextChild) : undefined;
|
|
145
|
+
const nextLabel = next ? nodeLabel(next) : "";
|
|
146
|
+
const summary = condition
|
|
147
|
+
? `Condition "${label}" was true${nextLabel ? `, continued to "${nextLabel}"` : ""}.`
|
|
148
|
+
: `Condition "${label}" was false${nextLabel ? `, continued to "${nextLabel}"` : ", so the workflow stopped"}.`;
|
|
149
|
+
completed = {
|
|
150
|
+
step: {
|
|
151
|
+
nodeId: node.id,
|
|
152
|
+
label,
|
|
153
|
+
type: "conditional",
|
|
154
|
+
status: "completed",
|
|
155
|
+
input: stored(condition),
|
|
156
|
+
output: stored({ condition: Boolean(condition), nextNodeId: next?.id ?? null }),
|
|
157
|
+
summary,
|
|
158
|
+
startedAt,
|
|
159
|
+
finishedAt: new Date(),
|
|
160
|
+
},
|
|
161
|
+
next,
|
|
162
|
+
};
|
|
163
|
+
} else {
|
|
164
|
+
throw new Error(`Unknown step type on "${label}"`);
|
|
165
|
+
}
|
|
166
|
+
} catch (error) {
|
|
167
|
+
try {
|
|
168
|
+
await onStep?.({
|
|
169
|
+
nodeId: node.id,
|
|
170
|
+
label,
|
|
171
|
+
...(node.core?.type === "task" || node.core?.type === "conditional" ? { type: node.core.type } : {}),
|
|
172
|
+
status: "failed",
|
|
173
|
+
error: { message: error?.message || String(error), stack: error?.stack },
|
|
174
|
+
summary: `Failed at "${label}": ${error?.message || error}`,
|
|
175
|
+
startedAt,
|
|
176
|
+
finishedAt: new Date(),
|
|
177
|
+
});
|
|
178
|
+
} catch (logError) {
|
|
179
|
+
console.error("@avakado.ai/schemas: failed to record workflow step", logError?.message || logError);
|
|
180
|
+
}
|
|
181
|
+
error.workflowOutputs = outputs;
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
await onStep?.(completed.step);
|
|
185
|
+
if (completed.next) await execute(completed.next);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
await execute(root);
|
|
189
|
+
return outputs;
|
|
190
|
+
}
|