@mugwork/mug 0.1.0
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 +21 -0
- package/README.md +251 -0
- package/dist/explorer.js +3 -0
- package/dist/packages/email-template/src/email-template.d.ts +18 -0
- package/dist/packages/email-template/src/email-template.js +74 -0
- package/dist/packages/email-template/src/index.d.ts +1 -0
- package/dist/packages/email-template/src/index.js +1 -0
- package/dist/packages/surface-renderer/src/form-renderer.d.ts +117 -0
- package/dist/packages/surface-renderer/src/form-renderer.js +719 -0
- package/dist/packages/surface-renderer/src/index.d.ts +4 -0
- package/dist/packages/surface-renderer/src/index.js +2 -0
- package/dist/packages/surface-renderer/src/portal-renderer.d.ts +177 -0
- package/dist/packages/surface-renderer/src/portal-renderer.js +1089 -0
- package/dist/packages/surface-renderer/src/workspace-home.d.ts +46 -0
- package/dist/packages/surface-renderer/src/workspace-home.js +345 -0
- package/dist/runtime/agent-types.d.ts +48 -0
- package/dist/runtime/agent-types.js +3 -0
- package/dist/runtime/ai-router.d.ts +32 -0
- package/dist/runtime/ai-router.js +112 -0
- package/dist/runtime/app.d.ts +6 -0
- package/dist/runtime/app.js +399 -0
- package/dist/runtime/chunker.d.ts +6 -0
- package/dist/runtime/chunker.js +30 -0
- package/dist/runtime/context.d.ts +115 -0
- package/dist/runtime/context.js +440 -0
- package/dist/runtime/do/workspace-database.d.ts +10 -0
- package/dist/runtime/do/workspace-database.js +199 -0
- package/dist/runtime/form-types.d.ts +143 -0
- package/dist/runtime/form-types.js +1 -0
- package/dist/runtime/runtime.d.ts +9 -0
- package/dist/runtime/runtime.js +7 -0
- package/dist/runtime/source-types.d.ts +15 -0
- package/dist/runtime/source-types.js +1 -0
- package/dist/runtime/source.d.ts +70 -0
- package/dist/runtime/source.js +21 -0
- package/dist/runtime/sync-runtime.d.ts +10 -0
- package/dist/runtime/sync-runtime.js +185 -0
- package/dist/runtime/types.d.ts +21 -0
- package/dist/runtime/types.js +1 -0
- package/dist/runtime/workflow-entrypoint.d.ts +31 -0
- package/dist/runtime/workflow-entrypoint.js +1297 -0
- package/dist/runtime/workflow.d.ts +285 -0
- package/dist/runtime/workflow.js +1008 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +44116 -0
- package/dist/src/commands/ai-gateway-route.d.ts +24 -0
- package/dist/src/commands/ai-gateway-route.js +192 -0
- package/dist/src/commands/auth.d.ts +1 -0
- package/dist/src/commands/auth.js +42 -0
- package/dist/src/commands/billing.d.ts +6 -0
- package/dist/src/commands/billing.js +76 -0
- package/dist/src/commands/brain.d.ts +1 -0
- package/dist/src/commands/brain.js +194 -0
- package/dist/src/commands/demo.d.ts +12 -0
- package/dist/src/commands/demo.js +147 -0
- package/dist/src/commands/deploy.d.ts +1 -0
- package/dist/src/commands/deploy.js +1052 -0
- package/dist/src/commands/dev.d.ts +14 -0
- package/dist/src/commands/dev.js +2818 -0
- package/dist/src/commands/form.d.ts +8 -0
- package/dist/src/commands/form.js +396 -0
- package/dist/src/commands/init.d.ts +1 -0
- package/dist/src/commands/init.js +139 -0
- package/dist/src/commands/issue.d.ts +7 -0
- package/dist/src/commands/issue.js +191 -0
- package/dist/src/commands/login.d.ts +9 -0
- package/dist/src/commands/login.js +163 -0
- package/dist/src/commands/logs.d.ts +8 -0
- package/dist/src/commands/logs.js +113 -0
- package/dist/src/commands/portal.d.ts +2 -0
- package/dist/src/commands/portal.js +111 -0
- package/dist/src/commands/pull.d.ts +3 -0
- package/dist/src/commands/pull.js +184 -0
- package/dist/src/commands/push.d.ts +4 -0
- package/dist/src/commands/push.js +183 -0
- package/dist/src/commands/run.d.ts +6 -0
- package/dist/src/commands/run.js +91 -0
- package/dist/src/commands/secret.d.ts +7 -0
- package/dist/src/commands/secret.js +105 -0
- package/dist/src/commands/shutdown.d.ts +1 -0
- package/dist/src/commands/shutdown.js +46 -0
- package/dist/src/commands/sql.d.ts +8 -0
- package/dist/src/commands/sql.js +142 -0
- package/dist/src/commands/status.d.ts +5 -0
- package/dist/src/commands/status.js +39 -0
- package/dist/src/commands/sync.d.ts +7 -0
- package/dist/src/commands/sync.js +991 -0
- package/dist/src/commands/usage.d.ts +6 -0
- package/dist/src/commands/usage.js +78 -0
- package/dist/src/commands/webhooks.d.ts +1 -0
- package/dist/src/commands/webhooks.js +102 -0
- package/dist/src/commands/workspace.d.ts +23 -0
- package/dist/src/commands/workspace.js +590 -0
- package/dist/src/connector-migration.d.ts +20 -0
- package/dist/src/connector-migration.js +43 -0
- package/dist/src/connector-parser.d.ts +14 -0
- package/dist/src/connector-parser.js +94 -0
- package/dist/src/connector-service/discover.d.ts +37 -0
- package/dist/src/connector-service/discover.js +79 -0
- package/dist/src/connector-service/gather.d.ts +22 -0
- package/dist/src/connector-service/gather.js +89 -0
- package/dist/src/connector-service/init.d.ts +14 -0
- package/dist/src/connector-service/init.js +109 -0
- package/dist/src/connector-service/scaffold.d.ts +17 -0
- package/dist/src/connector-service/scaffold.js +194 -0
- package/dist/src/connector-service/spec-storage.d.ts +8 -0
- package/dist/src/connector-service/spec-storage.js +48 -0
- package/dist/src/connector-service/types.d.ts +57 -0
- package/dist/src/connector-service/types.js +2 -0
- package/dist/src/connector-service/verify.d.ts +24 -0
- package/dist/src/connector-service/verify.js +575 -0
- package/dist/src/email-template.d.ts +2 -0
- package/dist/src/email-template.js +1 -0
- package/dist/src/manifest.d.ts +31 -0
- package/dist/src/manifest.js +25 -0
- package/dist/src/mug-icon.d.ts +1 -0
- package/dist/src/mug-icon.js +12 -0
- package/dist/src/slack-manifest.d.ts +119 -0
- package/dist/src/slack-manifest.js +163 -0
- package/dist/src/source-migration.d.ts +20 -0
- package/dist/src/source-migration.js +43 -0
- package/dist/src/surface-renderer.d.ts +5 -0
- package/dist/src/surface-renderer.js +3 -0
- package/dist/src/templates.d.ts +3 -0
- package/dist/src/templates.js +48 -0
- package/dist/src/version-check.d.ts +1 -0
- package/dist/src/version-check.js +28 -0
- package/dist/src/workflow-parser.d.ts +95 -0
- package/dist/src/workflow-parser.js +526 -0
- package/dist/worker/src/agent-types.d.ts +27 -0
- package/dist/worker/src/agent-types.js +3 -0
- package/dist/worker/src/source-types.d.ts +14 -0
- package/dist/worker/src/source-types.js +1 -0
- package/package.json +90 -0
- package/src/data/model-capabilities.json +171 -0
|
@@ -0,0 +1,1297 @@
|
|
|
1
|
+
import { WorkflowEntrypoint } from "cloudflare:workers";
|
|
2
|
+
import { getWorkflow, HttpError } from "./workflow.js";
|
|
3
|
+
import { scoreComplexity, resolveModel, parseModelSpec, resolveBilling } from "./ai-router.js";
|
|
4
|
+
import { getConnector } from "./source.js";
|
|
5
|
+
function titleCase(slug) {
|
|
6
|
+
return (slug ?? "Mug").split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
7
|
+
}
|
|
8
|
+
function isBillableStep(type) {
|
|
9
|
+
return type === "exec" || type === "http" || type.startsWith("action.");
|
|
10
|
+
}
|
|
11
|
+
function truncate(value, maxLen = 4096) {
|
|
12
|
+
const s = JSON.stringify(value);
|
|
13
|
+
return s.length > maxLen ? s.slice(0, maxLen) + "…" : s;
|
|
14
|
+
}
|
|
15
|
+
const DEFAULT_MAX_OPERATIONS = 100;
|
|
16
|
+
class DurableWorkflowContext {
|
|
17
|
+
env;
|
|
18
|
+
step;
|
|
19
|
+
stepCounter = 0;
|
|
20
|
+
operationCount = 0;
|
|
21
|
+
maxOperations;
|
|
22
|
+
steps = [];
|
|
23
|
+
params = {};
|
|
24
|
+
changesetId;
|
|
25
|
+
changesetSource;
|
|
26
|
+
instanceId;
|
|
27
|
+
responded = false;
|
|
28
|
+
constructor(env, step, maxOperations) {
|
|
29
|
+
this.env = env;
|
|
30
|
+
this.step = step;
|
|
31
|
+
this.maxOperations = maxOperations ?? DEFAULT_MAX_OPERATIONS;
|
|
32
|
+
}
|
|
33
|
+
secret(name) {
|
|
34
|
+
const val = this.env[name];
|
|
35
|
+
if (typeof val !== "string")
|
|
36
|
+
throw new Error(`Secret "${name}" not found`);
|
|
37
|
+
return val;
|
|
38
|
+
}
|
|
39
|
+
get isDemo() {
|
|
40
|
+
return this.params._demo === true;
|
|
41
|
+
}
|
|
42
|
+
get demoNotify() {
|
|
43
|
+
return this.params._demoNotify ?? null;
|
|
44
|
+
}
|
|
45
|
+
resolveDemoRecipient(channel, originalTo) {
|
|
46
|
+
const cfg = this.demoNotify;
|
|
47
|
+
if (!cfg)
|
|
48
|
+
return originalTo;
|
|
49
|
+
if (cfg.overrides?.[channel])
|
|
50
|
+
return cfg.overrides[channel];
|
|
51
|
+
switch (cfg.mode) {
|
|
52
|
+
case "off":
|
|
53
|
+
return null;
|
|
54
|
+
case "demo-user": {
|
|
55
|
+
const isEmail = cfg.identity.includes("@");
|
|
56
|
+
if (channel === "email" && isEmail)
|
|
57
|
+
return cfg.identity;
|
|
58
|
+
if (channel === "sms" && !isEmail)
|
|
59
|
+
return cfg.identity;
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
case "dev": {
|
|
63
|
+
if (channel === "email" && cfg.devEmail)
|
|
64
|
+
return cfg.devEmail;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
default:
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
nextStep(type, target) {
|
|
72
|
+
this.stepCounter++;
|
|
73
|
+
return `${type}-${target}-${this.stepCounter}`;
|
|
74
|
+
}
|
|
75
|
+
recordStep(name, type, startMs, opts) {
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
const log = {
|
|
78
|
+
step_name: name,
|
|
79
|
+
step_type: type,
|
|
80
|
+
billable: isBillableStep(type),
|
|
81
|
+
started_at: new Date(startMs).toISOString(),
|
|
82
|
+
completed_at: new Date(now).toISOString(),
|
|
83
|
+
duration_ms: now - startMs,
|
|
84
|
+
};
|
|
85
|
+
if (opts?.input != null)
|
|
86
|
+
log.input = truncate(opts.input);
|
|
87
|
+
if (opts?.output != null)
|
|
88
|
+
log.output = truncate(opts.output);
|
|
89
|
+
if (opts?.error)
|
|
90
|
+
log.error = opts.error;
|
|
91
|
+
if (opts?.tokensUsed)
|
|
92
|
+
log.tokens_used = opts.tokensUsed;
|
|
93
|
+
this.steps.push(log);
|
|
94
|
+
}
|
|
95
|
+
internalHeaders() {
|
|
96
|
+
return {
|
|
97
|
+
"Content-Type": "application/json",
|
|
98
|
+
"X-Mug-Internal": this.env.MUG_INTERNAL_SECRET ?? "",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
async query(database, sql, params) {
|
|
102
|
+
const name = this.nextStep("query", database);
|
|
103
|
+
const start = Date.now();
|
|
104
|
+
try {
|
|
105
|
+
const rows = await this.step.do(name, async () => {
|
|
106
|
+
const res = await this.env.MUG_DATA.fetch(`https://mug-data/workspace/${this.env.WORKSPACE_ID}/db/${database}/query`, {
|
|
107
|
+
method: "POST",
|
|
108
|
+
body: JSON.stringify({ sql, params }),
|
|
109
|
+
headers: this.internalHeaders(),
|
|
110
|
+
});
|
|
111
|
+
const data = (await res.json());
|
|
112
|
+
return data.rows;
|
|
113
|
+
});
|
|
114
|
+
this.recordStep(name, "query", start, { input: { sql, params }, output: `${rows.length} rows` });
|
|
115
|
+
return rows;
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
this.recordStep(name, "query", start, { input: { sql, params }, error: e.message });
|
|
119
|
+
throw e;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async exec(database, sql, params) {
|
|
123
|
+
const name = this.nextStep("exec", database);
|
|
124
|
+
const start = Date.now();
|
|
125
|
+
try {
|
|
126
|
+
const changes = await this.step.do(name, async () => {
|
|
127
|
+
const headers = this.internalHeaders();
|
|
128
|
+
if (this.changesetId)
|
|
129
|
+
headers["X-Changeset-Id"] = this.changesetId;
|
|
130
|
+
if (this.changesetSource)
|
|
131
|
+
headers["X-Changeset-Source"] = this.changesetSource;
|
|
132
|
+
const res = await this.env.MUG_DATA.fetch(`https://mug-data/workspace/${this.env.WORKSPACE_ID}/db/${database}/exec`, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
body: JSON.stringify({ sql, params }),
|
|
135
|
+
headers,
|
|
136
|
+
});
|
|
137
|
+
const data = (await res.json());
|
|
138
|
+
return data.changes;
|
|
139
|
+
});
|
|
140
|
+
if (changes > 0) {
|
|
141
|
+
await this.meterIncrement("operations", 1, `workflow:exec-${database}`);
|
|
142
|
+
}
|
|
143
|
+
this.recordStep(name, "exec", start, { input: { sql, params }, output: `${changes} changes` });
|
|
144
|
+
return changes;
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
this.recordStep(name, "exec", start, { input: { sql, params }, error: e.message });
|
|
148
|
+
throw e;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
getWorkspaceRouting() {
|
|
152
|
+
if (!this.env.MUG_AI_ROUTING)
|
|
153
|
+
return undefined;
|
|
154
|
+
try {
|
|
155
|
+
return JSON.parse(this.env.MUG_AI_ROUTING);
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
getWorkspaceBilling() {
|
|
162
|
+
if (!this.env.MUG_AI_BILLING)
|
|
163
|
+
return undefined;
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(this.env.MUG_AI_BILLING);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async meterCheck(dimension) {
|
|
172
|
+
if (!this.env.MUG_DISPATCH)
|
|
173
|
+
return { allowed: true, used: 0, limit: 0, remaining: 0 };
|
|
174
|
+
const res = await this.env.MUG_DISPATCH.fetch(`https://mug-dispatch/meter/${this.env.WORKSPACE_ID}/check`, {
|
|
175
|
+
method: "POST",
|
|
176
|
+
body: JSON.stringify({ dimension }),
|
|
177
|
+
headers: this.internalHeaders(),
|
|
178
|
+
});
|
|
179
|
+
return res.json();
|
|
180
|
+
}
|
|
181
|
+
async meterIncrement(dimension, delta, source) {
|
|
182
|
+
if (!this.env.MUG_DISPATCH)
|
|
183
|
+
return;
|
|
184
|
+
await this.env.MUG_DISPATCH.fetch(`https://mug-dispatch/meter/${this.env.WORKSPACE_ID}/increment`, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
body: JSON.stringify({ dimension, delta, source }),
|
|
187
|
+
headers: this.internalHeaders(),
|
|
188
|
+
}).catch(() => { });
|
|
189
|
+
}
|
|
190
|
+
async ai(model, options) {
|
|
191
|
+
const name = this.nextStep("ai", model);
|
|
192
|
+
const start = Date.now();
|
|
193
|
+
try {
|
|
194
|
+
const data = await this.step.do(name, { retries: { limit: 2, delay: "5 seconds", backoff: "exponential" } }, async () => {
|
|
195
|
+
if (!this.env.MUG_AI)
|
|
196
|
+
throw new Error("AI not configured: missing MUG_AI service binding");
|
|
197
|
+
let provider;
|
|
198
|
+
let resolvedModel;
|
|
199
|
+
let tier = null;
|
|
200
|
+
let routingMeta;
|
|
201
|
+
const tierNames = ["fast", "balanced", "powerful"];
|
|
202
|
+
if (model === "auto") {
|
|
203
|
+
const score = scoreComplexity(options.prompt, options);
|
|
204
|
+
tier = score.tier;
|
|
205
|
+
const resolved = resolveModel(tier, options.routing, this.getWorkspaceRouting());
|
|
206
|
+
provider = resolved.provider;
|
|
207
|
+
resolvedModel = resolved.model;
|
|
208
|
+
routingMeta = { tier, model: resolvedModel, provider, reason: score.reason };
|
|
209
|
+
}
|
|
210
|
+
else if (tierNames.includes(model)) {
|
|
211
|
+
tier = model;
|
|
212
|
+
const resolved = resolveModel(tier, options.routing, this.getWorkspaceRouting());
|
|
213
|
+
provider = resolved.provider;
|
|
214
|
+
resolvedModel = resolved.model;
|
|
215
|
+
routingMeta = { tier, model: resolvedModel, provider, reason: `tier:${tier}` };
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
const parsed = parseModelSpec(model);
|
|
219
|
+
provider = parsed.provider;
|
|
220
|
+
resolvedModel = parsed.model;
|
|
221
|
+
}
|
|
222
|
+
const billingKey = resolveBilling(tier, options.billing, undefined, this.getWorkspaceBilling());
|
|
223
|
+
const billing = billingKey !== "mug-metered"
|
|
224
|
+
? this.env[billingKey] ?? billingKey
|
|
225
|
+
: billingKey;
|
|
226
|
+
const aiCheck = await this.meterCheck("ai_credits");
|
|
227
|
+
if (aiCheck.remaining === 0 && aiCheck.limit > 0) {
|
|
228
|
+
throw new Error(`AI credit limit exceeded: ${aiCheck.used}/${aiCheck.limit}`);
|
|
229
|
+
}
|
|
230
|
+
const res = await this.env.MUG_AI.fetch("https://mug-ai/complete", {
|
|
231
|
+
method: "POST",
|
|
232
|
+
body: JSON.stringify({
|
|
233
|
+
workspace: this.env.WORKSPACE_ID,
|
|
234
|
+
provider,
|
|
235
|
+
model: resolvedModel,
|
|
236
|
+
prompt: options.prompt,
|
|
237
|
+
system: options.system,
|
|
238
|
+
maxTokens: options.maxTokens,
|
|
239
|
+
routing: routingMeta ? { tier: routingMeta.tier, reason: routingMeta.reason } : undefined,
|
|
240
|
+
billing,
|
|
241
|
+
source: "workflow",
|
|
242
|
+
}),
|
|
243
|
+
headers: this.internalHeaders(),
|
|
244
|
+
});
|
|
245
|
+
if (!res.ok)
|
|
246
|
+
throw new Error(`AI request failed (${res.status}): ${await res.text()}`);
|
|
247
|
+
const result = (await res.json());
|
|
248
|
+
if (routingMeta)
|
|
249
|
+
result.routing = routingMeta;
|
|
250
|
+
// Provisional estimate using Sonnet 4.6 rates (snapshotted 2026-06-19) — cron corrects to real cost within 15 min
|
|
251
|
+
const estCost = result.usage.input_tokens * (3.00 / 1e6) + result.usage.output_tokens * (15.00 / 1e6);
|
|
252
|
+
const credits = Math.max(1, Math.ceil(estCost / 0.001));
|
|
253
|
+
if (credits > 0 && billing === "mug-metered") {
|
|
254
|
+
await this.meterIncrement("ai_credits", credits, `workflow:ai-${resolvedModel}`);
|
|
255
|
+
}
|
|
256
|
+
return result;
|
|
257
|
+
});
|
|
258
|
+
this.recordStep(name, "ai", start, {
|
|
259
|
+
input: { prompt: options.prompt.slice(0, 200) },
|
|
260
|
+
output: data.text.slice(0, 200),
|
|
261
|
+
tokensUsed: data.usage.input_tokens + data.usage.output_tokens,
|
|
262
|
+
});
|
|
263
|
+
return data;
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
this.recordStep(name, "ai", start, { input: { prompt: options.prompt.slice(0, 200) }, error: e.message });
|
|
267
|
+
throw e;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async search(query, options) {
|
|
271
|
+
const name = this.nextStep("search", options?.source ?? "all");
|
|
272
|
+
const start = Date.now();
|
|
273
|
+
try {
|
|
274
|
+
const results = await this.step.do(name, async () => {
|
|
275
|
+
if (!this.env.MUG_AI)
|
|
276
|
+
throw new Error("Search not available — no MUG_AI binding");
|
|
277
|
+
if (!this.env.MUG_DISPATCH)
|
|
278
|
+
throw new Error("Search not available — no MUG_DISPATCH binding");
|
|
279
|
+
const limit = Math.min(options?.limit ?? 10, 50);
|
|
280
|
+
const embedRes = await this.env.MUG_AI.fetch("https://mug-ai/embed", {
|
|
281
|
+
method: "POST",
|
|
282
|
+
body: JSON.stringify({ workspace: this.env.WORKSPACE_ID, texts: [query] }),
|
|
283
|
+
headers: this.internalHeaders(),
|
|
284
|
+
});
|
|
285
|
+
if (!embedRes.ok)
|
|
286
|
+
throw new Error(`Embed failed: ${await embedRes.text()}`);
|
|
287
|
+
const { vectors } = (await embedRes.json());
|
|
288
|
+
const filter = { ...options?.filter };
|
|
289
|
+
if (options?.source)
|
|
290
|
+
filter.table = options.source;
|
|
291
|
+
const queryRes = await this.env.MUG_DISPATCH.fetch(`https://mug-dispatch/vector/${this.env.WORKSPACE_ID}/query`, {
|
|
292
|
+
method: "POST",
|
|
293
|
+
headers: this.internalHeaders(),
|
|
294
|
+
body: JSON.stringify({
|
|
295
|
+
vector: vectors[0],
|
|
296
|
+
topK: limit * 2,
|
|
297
|
+
returnMetadata: true,
|
|
298
|
+
...(Object.keys(filter).length > 0 ? { filter } : {}),
|
|
299
|
+
}),
|
|
300
|
+
});
|
|
301
|
+
const queryData = await queryRes.json();
|
|
302
|
+
const matches = (queryData.result?.matches ?? queryData.matches ?? []);
|
|
303
|
+
const best = new Map();
|
|
304
|
+
for (const match of matches) {
|
|
305
|
+
const meta = match.metadata;
|
|
306
|
+
if (!meta?.table || !meta?.primary_key)
|
|
307
|
+
continue;
|
|
308
|
+
const key = `${meta.table}:${meta.primary_key}`;
|
|
309
|
+
const existing = best.get(key);
|
|
310
|
+
if (!existing || match.score > existing.score) {
|
|
311
|
+
best.set(key, { score: match.score, meta });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const searchResults = [];
|
|
315
|
+
for (const [, { score, meta }] of best) {
|
|
316
|
+
if (searchResults.length >= limit)
|
|
317
|
+
break;
|
|
318
|
+
try {
|
|
319
|
+
const qRes = await this.env.MUG_DATA.fetch(`https://mug-data/workspace/${this.env.WORKSPACE_ID}/db/${meta.database}/query`, {
|
|
320
|
+
method: "POST",
|
|
321
|
+
body: JSON.stringify({
|
|
322
|
+
sql: `SELECT * FROM "${meta.table}" WHERE "${meta.pk_column}" = ? AND _mug_deleted_at IS NULL`,
|
|
323
|
+
params: [meta.primary_key],
|
|
324
|
+
}),
|
|
325
|
+
headers: this.internalHeaders(),
|
|
326
|
+
});
|
|
327
|
+
const { rows } = (await qRes.json());
|
|
328
|
+
searchResults.push({ score, table: meta.table, primaryKey: meta.primary_key, row: rows[0] ?? {} });
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
searchResults.push({ score, table: meta.table, primaryKey: meta.primary_key, row: {} });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return searchResults;
|
|
335
|
+
});
|
|
336
|
+
this.recordStep(name, "search", start, {
|
|
337
|
+
input: { query: query.slice(0, 200), source: options?.source },
|
|
338
|
+
output: `${results.length} results`,
|
|
339
|
+
});
|
|
340
|
+
return results;
|
|
341
|
+
}
|
|
342
|
+
catch (e) {
|
|
343
|
+
this.recordStep(name, "search", start, { input: { query: query.slice(0, 200) }, error: e.message });
|
|
344
|
+
throw e;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async ask(question, options) {
|
|
348
|
+
const name = this.nextStep("ask", options?.source ?? "all");
|
|
349
|
+
const start = Date.now();
|
|
350
|
+
try {
|
|
351
|
+
const result = await this.step.do(name, async () => {
|
|
352
|
+
const sources = await this.search(question, {
|
|
353
|
+
source: options?.source,
|
|
354
|
+
limit: options?.limit ?? 10,
|
|
355
|
+
});
|
|
356
|
+
const contextParts = [];
|
|
357
|
+
let tokenEstimate = 0;
|
|
358
|
+
for (const r of sources) {
|
|
359
|
+
const entry = `[${r.table}:${r.primaryKey} score=${r.score.toFixed(3)}]\n${JSON.stringify(r.row)}`;
|
|
360
|
+
const entryTokens = Math.ceil(entry.split(/\s+/).length * 1.3);
|
|
361
|
+
if (tokenEstimate + entryTokens > 3000)
|
|
362
|
+
break;
|
|
363
|
+
contextParts.push(entry);
|
|
364
|
+
tokenEstimate += entryTokens;
|
|
365
|
+
}
|
|
366
|
+
const baseSystem = "Answer the question based on the following business data. Cite which records informed your answer. If the data does not contain enough information, say so.";
|
|
367
|
+
const dataBlock = `\n\n--- Business Data ---\n${contextParts.join("\n\n")}`;
|
|
368
|
+
const system = (options?.system ? `${options.system}\n\n${baseSystem}` : baseSystem) + dataBlock;
|
|
369
|
+
const aiResult = await this.ai(options?.model ?? "balanced", {
|
|
370
|
+
prompt: question,
|
|
371
|
+
system,
|
|
372
|
+
});
|
|
373
|
+
return {
|
|
374
|
+
answer: aiResult.text,
|
|
375
|
+
sources,
|
|
376
|
+
usage: {
|
|
377
|
+
input_tokens: aiResult.usage.input_tokens,
|
|
378
|
+
output_tokens: aiResult.usage.output_tokens,
|
|
379
|
+
search_results: sources.length,
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
});
|
|
383
|
+
this.recordStep(name, "ask", start, {
|
|
384
|
+
input: { question: question.slice(0, 200), source: options?.source },
|
|
385
|
+
output: result.answer.slice(0, 200),
|
|
386
|
+
tokensUsed: result.usage.input_tokens + result.usage.output_tokens,
|
|
387
|
+
});
|
|
388
|
+
return result;
|
|
389
|
+
}
|
|
390
|
+
catch (e) {
|
|
391
|
+
this.recordStep(name, "ask", start, { input: { question: question.slice(0, 200) }, error: e.message });
|
|
392
|
+
throw e;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
async file(path) {
|
|
396
|
+
const name = this.nextStep("file", path);
|
|
397
|
+
const start = Date.now();
|
|
398
|
+
try {
|
|
399
|
+
const buffer = await this.step.do(name, async () => {
|
|
400
|
+
const res = await this.env.MUG_DATA.fetch(`https://mug-data/workspace/${this.env.WORKSPACE_ID}/files/read/${path}`, { headers: this.internalHeaders() });
|
|
401
|
+
if (!res.ok)
|
|
402
|
+
throw new Error(`File not found: ${path} (${res.status})`);
|
|
403
|
+
return res.arrayBuffer();
|
|
404
|
+
});
|
|
405
|
+
this.recordStep(name, "file", start, { input: { path }, output: `${buffer.byteLength} bytes` });
|
|
406
|
+
return buffer;
|
|
407
|
+
}
|
|
408
|
+
catch (e) {
|
|
409
|
+
this.recordStep(name, "file", start, { input: { path }, error: e.message });
|
|
410
|
+
throw e;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
async fileText(path) {
|
|
414
|
+
const buffer = await this.file(path);
|
|
415
|
+
return new TextDecoder().decode(buffer);
|
|
416
|
+
}
|
|
417
|
+
async embed(texts) {
|
|
418
|
+
if (texts.length === 0)
|
|
419
|
+
return [];
|
|
420
|
+
const name = this.nextStep("embed", `${texts.length} texts`);
|
|
421
|
+
const start = Date.now();
|
|
422
|
+
try {
|
|
423
|
+
const results = await this.step.do(name, async () => {
|
|
424
|
+
if (!this.env.MUG_AI)
|
|
425
|
+
throw new Error("Embed not available — no MUG_AI binding");
|
|
426
|
+
const allVectors = [];
|
|
427
|
+
for (let i = 0; i < texts.length; i += 100) {
|
|
428
|
+
const batch = texts.slice(i, i + 100);
|
|
429
|
+
const res = await this.env.MUG_AI.fetch("https://mug-ai/embed", {
|
|
430
|
+
method: "POST",
|
|
431
|
+
body: JSON.stringify({ workspace: this.env.WORKSPACE_ID, texts: batch }),
|
|
432
|
+
headers: this.internalHeaders(),
|
|
433
|
+
});
|
|
434
|
+
if (!res.ok)
|
|
435
|
+
throw new Error(`Embed failed: ${await res.text()}`);
|
|
436
|
+
const { vectors } = (await res.json());
|
|
437
|
+
allVectors.push(...vectors);
|
|
438
|
+
}
|
|
439
|
+
return allVectors;
|
|
440
|
+
});
|
|
441
|
+
this.recordStep(name, "embed", start, { input: { count: texts.length }, output: `${results.length} vectors` });
|
|
442
|
+
return results;
|
|
443
|
+
}
|
|
444
|
+
catch (e) {
|
|
445
|
+
this.recordStep(name, "embed", start, { input: { count: texts.length }, error: e.message });
|
|
446
|
+
throw e;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
async slackApiCall(method, body) {
|
|
450
|
+
const name = this.nextStep("slack.api", method);
|
|
451
|
+
const start = Date.now();
|
|
452
|
+
try {
|
|
453
|
+
const data = await this.step.do(name, async () => {
|
|
454
|
+
const token = this.env.SLACK_BOT_TOKEN;
|
|
455
|
+
if (!token)
|
|
456
|
+
throw new Error("SLACK_BOT_TOKEN not configured");
|
|
457
|
+
const res = await fetch(`https://slack.com/api/${method}`, {
|
|
458
|
+
method: "POST",
|
|
459
|
+
headers: {
|
|
460
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
461
|
+
Authorization: `Bearer ${token}`,
|
|
462
|
+
},
|
|
463
|
+
body: JSON.stringify(body),
|
|
464
|
+
});
|
|
465
|
+
const result = await res.json();
|
|
466
|
+
if (!result.ok)
|
|
467
|
+
throw new Error(`Slack API ${method} failed: ${result.error ?? "unknown error"}`);
|
|
468
|
+
return result;
|
|
469
|
+
});
|
|
470
|
+
this.recordStep(name, "slack.api", start, { input: { method }, output: "ok" });
|
|
471
|
+
return data;
|
|
472
|
+
}
|
|
473
|
+
catch (e) {
|
|
474
|
+
this.recordStep(name, "slack.api", start, { input: { method }, error: e.message });
|
|
475
|
+
throw e;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
surfaceUrl(surfaceId, path) {
|
|
479
|
+
return `https://${this.env.WORKSPACE_ID}.mug.work/${surfaceId}${path ?? ""}`;
|
|
480
|
+
}
|
|
481
|
+
getBranding() {
|
|
482
|
+
if (!this.env.MUG_BRANDING)
|
|
483
|
+
return undefined;
|
|
484
|
+
try {
|
|
485
|
+
return JSON.parse(this.env.MUG_BRANDING);
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return undefined;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
get notify() {
|
|
492
|
+
return {
|
|
493
|
+
sms: (options) => this.sendNotification("sms", options),
|
|
494
|
+
email: (options) => this.sendNotification("email", options),
|
|
495
|
+
slack: (options) => this.sendNotification("slack", options),
|
|
496
|
+
channel: (name, options) => this.sendNotification(name, options),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
async sendNotification(channel, options) {
|
|
500
|
+
const name = this.nextStep("notify", channel);
|
|
501
|
+
const start = Date.now();
|
|
502
|
+
if (this.isDemo) {
|
|
503
|
+
const resolved = this.resolveDemoRecipient(channel, options.to);
|
|
504
|
+
if (resolved === null) {
|
|
505
|
+
this.recordStep(name, "notify", start, { input: { to: options.to }, output: `suppressed (demo mode: ${this.demoNotify?.mode ?? "off"})` });
|
|
506
|
+
return "suppressed";
|
|
507
|
+
}
|
|
508
|
+
options = { ...options, to: resolved };
|
|
509
|
+
}
|
|
510
|
+
const smsByok = channel === "sms" && !!(this.env.TELNYX_API_KEY || this.env.TWILIO_ACCOUNT_SID);
|
|
511
|
+
try {
|
|
512
|
+
const status = await this.step.do(name, { retries: { limit: 2, delay: "5 seconds", backoff: "exponential" } }, async () => {
|
|
513
|
+
if (this.env.MUG_NOTIFY) {
|
|
514
|
+
if ((channel === "sms" && !smsByok) || channel === "email") {
|
|
515
|
+
const dimension = channel === "sms" ? "sms" : "email";
|
|
516
|
+
const check = await this.meterCheck(dimension);
|
|
517
|
+
if (!check.allowed) {
|
|
518
|
+
throw new Error(`Usage limit exceeded for ${dimension}: ${check.used}/${check.limit}`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const fromName = options.fromName ?? titleCase(this.env.WORKSPACE_ID);
|
|
522
|
+
const res = await this.env.MUG_NOTIFY.fetch("https://mug-notify/send", {
|
|
523
|
+
method: "POST",
|
|
524
|
+
body: JSON.stringify({
|
|
525
|
+
workspace: this.env.WORKSPACE_ID,
|
|
526
|
+
channel,
|
|
527
|
+
to: options.to,
|
|
528
|
+
message: options.message,
|
|
529
|
+
subject: options.subject,
|
|
530
|
+
fromName,
|
|
531
|
+
cta: options.cta,
|
|
532
|
+
branding: this.getBranding(),
|
|
533
|
+
...(channel === "slack" ? {
|
|
534
|
+
blocks: options.blocks,
|
|
535
|
+
thread_ts: options.thread_ts,
|
|
536
|
+
unfurl_links: options.unfurl_links,
|
|
537
|
+
unfurl_media: options.unfurl_media,
|
|
538
|
+
slackBotToken: this.env.SLACK_BOT_TOKEN,
|
|
539
|
+
} : {}),
|
|
540
|
+
...(channel === "sms" ? {
|
|
541
|
+
telnyxApiKey: this.env.TELNYX_API_KEY,
|
|
542
|
+
telnyxPhoneNumber: this.env.TELNYX_PHONE_NUMBER,
|
|
543
|
+
twilioAccountSid: this.env.TWILIO_ACCOUNT_SID,
|
|
544
|
+
twilioAuthToken: this.env.TWILIO_AUTH_TOKEN,
|
|
545
|
+
twilioPhoneNumber: this.env.TWILIO_PHONE_NUMBER,
|
|
546
|
+
} : {}),
|
|
547
|
+
}),
|
|
548
|
+
headers: this.internalHeaders(),
|
|
549
|
+
});
|
|
550
|
+
if (!res.ok) {
|
|
551
|
+
const body = await res.text().catch(() => "");
|
|
552
|
+
throw new Error(`Notify failed (${res.status}): ${body}`);
|
|
553
|
+
}
|
|
554
|
+
if ((channel === "sms" && !smsByok) || channel === "email") {
|
|
555
|
+
await this.meterIncrement(channel === "sms" ? "sms" : "email", 1, `workflow:notify-${channel}`);
|
|
556
|
+
}
|
|
557
|
+
return "delivered";
|
|
558
|
+
}
|
|
559
|
+
else {
|
|
560
|
+
console.log(`[notify:${channel}] to=${options.to} message=${options.message}`);
|
|
561
|
+
return "logged";
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
this.recordStep(name, "notify", start, { input: { to: options.to }, output: status });
|
|
565
|
+
return status;
|
|
566
|
+
}
|
|
567
|
+
catch (e) {
|
|
568
|
+
this.recordStep(name, "notify", start, { input: { to: options.to }, error: e.message });
|
|
569
|
+
throw e;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
get slack() {
|
|
573
|
+
return {
|
|
574
|
+
updateMessage: (options) => this.slackUpdateMessage(options),
|
|
575
|
+
openModal: (options) => this.slackOpenModal(options),
|
|
576
|
+
updateModal: (options) => this.slackUpdateModal(options),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
async slackUpdateMessage(options) {
|
|
580
|
+
const name = this.nextStep("slack", "updateMessage");
|
|
581
|
+
const start = Date.now();
|
|
582
|
+
try {
|
|
583
|
+
await this.step.do(name, async () => {
|
|
584
|
+
const token = this.env.SLACK_BOT_TOKEN;
|
|
585
|
+
if (!token)
|
|
586
|
+
throw new Error("SLACK_BOT_TOKEN not configured");
|
|
587
|
+
const res = await fetch("https://slack.com/api/chat.update", {
|
|
588
|
+
method: "POST",
|
|
589
|
+
headers: {
|
|
590
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
591
|
+
Authorization: `Bearer ${token}`,
|
|
592
|
+
},
|
|
593
|
+
body: JSON.stringify({
|
|
594
|
+
channel: options.channel,
|
|
595
|
+
ts: options.ts,
|
|
596
|
+
text: options.text ?? "",
|
|
597
|
+
...(options.blocks ? { blocks: options.blocks } : {}),
|
|
598
|
+
}),
|
|
599
|
+
});
|
|
600
|
+
const data = await res.json();
|
|
601
|
+
if (!data.ok)
|
|
602
|
+
throw new Error(`Slack chat.update failed: ${data.error ?? "unknown"}`);
|
|
603
|
+
});
|
|
604
|
+
this.recordStep(name, "slack.updateMessage", start, { input: { channel: options.channel, ts: options.ts }, output: "updated" });
|
|
605
|
+
}
|
|
606
|
+
catch (e) {
|
|
607
|
+
this.recordStep(name, "slack.updateMessage", start, { input: { channel: options.channel, ts: options.ts }, error: e.message });
|
|
608
|
+
throw e;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
async slackOpenModal(options) {
|
|
612
|
+
const name = this.nextStep("slack", "openModal");
|
|
613
|
+
const start = Date.now();
|
|
614
|
+
try {
|
|
615
|
+
await this.step.do(name, async () => {
|
|
616
|
+
const token = this.env.SLACK_BOT_TOKEN;
|
|
617
|
+
if (!token)
|
|
618
|
+
throw new Error("SLACK_BOT_TOKEN not configured");
|
|
619
|
+
const res = await fetch("https://slack.com/api/views.open", {
|
|
620
|
+
method: "POST",
|
|
621
|
+
headers: {
|
|
622
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
623
|
+
Authorization: `Bearer ${token}`,
|
|
624
|
+
},
|
|
625
|
+
body: JSON.stringify({
|
|
626
|
+
trigger_id: options.triggerId,
|
|
627
|
+
view: options.view,
|
|
628
|
+
}),
|
|
629
|
+
});
|
|
630
|
+
const data = await res.json();
|
|
631
|
+
if (!data.ok)
|
|
632
|
+
throw new Error(`Slack views.open failed: ${data.error ?? "unknown"}`);
|
|
633
|
+
});
|
|
634
|
+
this.recordStep(name, "slack.openModal", start, { output: "opened" });
|
|
635
|
+
}
|
|
636
|
+
catch (e) {
|
|
637
|
+
this.recordStep(name, "slack.openModal", start, { error: e.message });
|
|
638
|
+
throw e;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
async slackUpdateModal(options) {
|
|
642
|
+
const name = this.nextStep("slack", "updateModal");
|
|
643
|
+
const start = Date.now();
|
|
644
|
+
try {
|
|
645
|
+
await this.step.do(name, async () => {
|
|
646
|
+
const token = this.env.SLACK_BOT_TOKEN;
|
|
647
|
+
if (!token)
|
|
648
|
+
throw new Error("SLACK_BOT_TOKEN not configured");
|
|
649
|
+
const res = await fetch("https://slack.com/api/views.update", {
|
|
650
|
+
method: "POST",
|
|
651
|
+
headers: {
|
|
652
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
653
|
+
Authorization: `Bearer ${token}`,
|
|
654
|
+
},
|
|
655
|
+
body: JSON.stringify({
|
|
656
|
+
view_id: options.viewId,
|
|
657
|
+
view: options.view,
|
|
658
|
+
}),
|
|
659
|
+
});
|
|
660
|
+
const data = await res.json();
|
|
661
|
+
if (!data.ok)
|
|
662
|
+
throw new Error(`Slack views.update failed: ${data.error ?? "unknown"}`);
|
|
663
|
+
});
|
|
664
|
+
this.recordStep(name, "slack.updateModal", start, { input: { viewId: options.viewId }, output: "updated" });
|
|
665
|
+
}
|
|
666
|
+
catch (e) {
|
|
667
|
+
this.recordStep(name, "slack.updateModal", start, { input: { viewId: options.viewId }, error: e.message });
|
|
668
|
+
throw e;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
async respond(body, status) {
|
|
672
|
+
if (this.responded)
|
|
673
|
+
return;
|
|
674
|
+
this.responded = true;
|
|
675
|
+
if (!this.env.MUG_DISPATCH || !this.instanceId)
|
|
676
|
+
return;
|
|
677
|
+
await this.step.do(this.nextStep("respond", "webhook"), async () => {
|
|
678
|
+
await this.env.MUG_DISPATCH.fetch("https://mug-dispatch/_internal/webhook-respond", {
|
|
679
|
+
method: "POST",
|
|
680
|
+
headers: this.internalHeaders(),
|
|
681
|
+
body: JSON.stringify({
|
|
682
|
+
workspace: this.env.WORKSPACE_ID,
|
|
683
|
+
instanceId: this.instanceId,
|
|
684
|
+
body,
|
|
685
|
+
status: status ?? 200,
|
|
686
|
+
}),
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
async agent(name, options) {
|
|
691
|
+
const stepName = this.nextStep("agent", name);
|
|
692
|
+
const start = Date.now();
|
|
693
|
+
const sessionKey = options.sessionKey ?? `${this.changesetId ?? "run"}-${name}`;
|
|
694
|
+
try {
|
|
695
|
+
const data = await this.step.do(stepName, async () => {
|
|
696
|
+
if (!this.env.MUG_DISPATCH)
|
|
697
|
+
throw new Error("Agent not configured: missing MUG_DISPATCH binding");
|
|
698
|
+
const res = await this.env.MUG_DISPATCH.fetch(`https://mug-dispatch/agent/${this.env.WORKSPACE_ID}/invoke`, {
|
|
699
|
+
method: "POST",
|
|
700
|
+
headers: this.internalHeaders(),
|
|
701
|
+
body: JSON.stringify({ agent: name, goal: options.goal, context: options.context, sessionKey, caps: options.caps }),
|
|
702
|
+
});
|
|
703
|
+
if (!res.ok) {
|
|
704
|
+
const errText = await res.text();
|
|
705
|
+
throw new Error(`Agent "${name}" failed: ${errText}`);
|
|
706
|
+
}
|
|
707
|
+
return res.json();
|
|
708
|
+
});
|
|
709
|
+
if (data.status === "pending_approval" && data.pendingApproval) {
|
|
710
|
+
this.recordStep(stepName, "agent", start, {
|
|
711
|
+
input: { goal: options.goal.slice(0, 200) },
|
|
712
|
+
output: `pending approval: ${data.pendingApproval.tool}`,
|
|
713
|
+
});
|
|
714
|
+
const approvalEvent = `agent-approval-${sessionKey}`;
|
|
715
|
+
const callbackUrl = await this.waitForUrl(approvalEvent);
|
|
716
|
+
const approveUrl = `${callbackUrl}?action=approve`;
|
|
717
|
+
await this.step.do(this.nextStep("notify", "agent-approval"), { retries: { limit: 1, delay: "2 seconds" } }, async () => {
|
|
718
|
+
if (this.env.MUG_NOTIFY) {
|
|
719
|
+
await this.env.MUG_NOTIFY.fetch("https://mug-notify/send", {
|
|
720
|
+
method: "POST",
|
|
721
|
+
headers: this.internalHeaders(),
|
|
722
|
+
body: JSON.stringify({
|
|
723
|
+
workspace: this.env.WORKSPACE_ID,
|
|
724
|
+
channel: "email",
|
|
725
|
+
to: options.approvalNotify ?? this.env.WORKSPACE_ID,
|
|
726
|
+
subject: `Approval needed: ${data.pendingApproval.tool}`,
|
|
727
|
+
message: `Agent "${name}" wants to execute ${data.pendingApproval.tool}.\n\n${JSON.stringify(data.pendingApproval.args, null, 2)}`,
|
|
728
|
+
cta: { label: "Approve", url: approveUrl },
|
|
729
|
+
}),
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
const event = await this.waitFor(approvalEvent, { timeout: "24 hours" });
|
|
734
|
+
const approved = !event.timedOut && event.payload?.action === "approve";
|
|
735
|
+
if (event.timedOut) {
|
|
736
|
+
return {
|
|
737
|
+
response: data.response,
|
|
738
|
+
output: data.output ?? undefined,
|
|
739
|
+
usage: { credits: data.usage?.credits ?? 0, turns: data.usage?.turns ?? 0, duration: Date.now() - start },
|
|
740
|
+
capped: true,
|
|
741
|
+
cappedReason: "approval_timeout",
|
|
742
|
+
pendingApproval: { tool: data.pendingApproval.tool, args: data.pendingApproval.args, sessionKey },
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
const resumeData = await this.step.do(this.nextStep("agent-resume", name), async () => {
|
|
746
|
+
const res = await this.env.MUG_DISPATCH.fetch(`https://mug-dispatch/agent/${this.env.WORKSPACE_ID}/approve`, {
|
|
747
|
+
method: "POST",
|
|
748
|
+
headers: this.internalHeaders(),
|
|
749
|
+
body: JSON.stringify({ sessionKey, approved }),
|
|
750
|
+
});
|
|
751
|
+
if (!res.ok)
|
|
752
|
+
throw new Error(`Agent approve failed: ${await res.text()}`);
|
|
753
|
+
return res.json();
|
|
754
|
+
});
|
|
755
|
+
const totalCredits = (data.usage?.credits ?? 0) + (resumeData.usage?.credits ?? 0);
|
|
756
|
+
const totalTurns = (data.usage?.turns ?? 0) + (resumeData.usage?.turns ?? 0);
|
|
757
|
+
const result = {
|
|
758
|
+
response: resumeData.response,
|
|
759
|
+
output: resumeData.output ?? data.output ?? undefined,
|
|
760
|
+
usage: { credits: totalCredits, turns: totalTurns, duration: Date.now() - start },
|
|
761
|
+
capped: resumeData.capped,
|
|
762
|
+
cappedReason: resumeData.cappedReason,
|
|
763
|
+
};
|
|
764
|
+
this.recordStep(this.nextStep("agent-complete", name), "agent", start, {
|
|
765
|
+
input: { goal: options.goal.slice(0, 200) },
|
|
766
|
+
output: resumeData.response?.slice(0, 200),
|
|
767
|
+
});
|
|
768
|
+
return result;
|
|
769
|
+
}
|
|
770
|
+
const duration = Date.now() - start;
|
|
771
|
+
const result = {
|
|
772
|
+
response: data.response,
|
|
773
|
+
output: data.output ?? undefined,
|
|
774
|
+
usage: { credits: data.usage?.credits ?? 0, turns: data.usage?.turns ?? 0, duration },
|
|
775
|
+
capped: data.capped,
|
|
776
|
+
cappedReason: data.cappedReason,
|
|
777
|
+
};
|
|
778
|
+
this.recordStep(stepName, "agent", start, {
|
|
779
|
+
input: { goal: options.goal.slice(0, 200) },
|
|
780
|
+
output: data.response?.slice(0, 200),
|
|
781
|
+
});
|
|
782
|
+
return result;
|
|
783
|
+
}
|
|
784
|
+
catch (e) {
|
|
785
|
+
this.recordStep(stepName, "agent", start, {
|
|
786
|
+
input: { goal: options.goal.slice(0, 200) },
|
|
787
|
+
error: e.message,
|
|
788
|
+
});
|
|
789
|
+
throw e;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
async collect(options) {
|
|
793
|
+
const name = this.nextStep("collect", options.title);
|
|
794
|
+
return this.step.do(name, async () => {
|
|
795
|
+
const surfaceId = options.id ?? crypto.randomUUID().slice(0, 8);
|
|
796
|
+
const workspace = this.env.WORKSPACE_ID;
|
|
797
|
+
const pages = options.pages ?? [{
|
|
798
|
+
id: "main",
|
|
799
|
+
fields: options.fields ?? [],
|
|
800
|
+
}];
|
|
801
|
+
const access = options.access ?? { mode: "public" };
|
|
802
|
+
const schema = {
|
|
803
|
+
title: options.title,
|
|
804
|
+
description: options.description,
|
|
805
|
+
submitText: options.submitText,
|
|
806
|
+
pages,
|
|
807
|
+
access,
|
|
808
|
+
editMode: options.editMode,
|
|
809
|
+
workflow: options.workflow,
|
|
810
|
+
};
|
|
811
|
+
const surfaceConfig = { workspace, surfaceId, ...schema };
|
|
812
|
+
await fetch("https://api.mug.work/deploy-surface", {
|
|
813
|
+
method: "POST",
|
|
814
|
+
body: JSON.stringify(surfaceConfig),
|
|
815
|
+
headers: this.internalHeaders(),
|
|
816
|
+
});
|
|
817
|
+
return `https://${workspace}.mug.work/${surfaceId}`;
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
async waitFor(eventName, options) {
|
|
821
|
+
const name = this.nextStep("waitFor", eventName);
|
|
822
|
+
const start = Date.now();
|
|
823
|
+
try {
|
|
824
|
+
const timeout = options?.timeout ?? "24 hours";
|
|
825
|
+
const cfTimeout = typeof timeout === "number" ? `${timeout} seconds` : timeout;
|
|
826
|
+
const event = await this.step.waitForEvent(name, {
|
|
827
|
+
type: eventName,
|
|
828
|
+
timeout: cfTimeout,
|
|
829
|
+
});
|
|
830
|
+
const result = {
|
|
831
|
+
payload: event.payload,
|
|
832
|
+
type: event.type,
|
|
833
|
+
timedOut: false,
|
|
834
|
+
};
|
|
835
|
+
this.recordStep(name, "waitFor", start, { output: truncate(result) });
|
|
836
|
+
return result;
|
|
837
|
+
}
|
|
838
|
+
catch (e) {
|
|
839
|
+
const msg = e.message ?? "";
|
|
840
|
+
if (msg.toLowerCase().includes("timeout") || msg.toLowerCase().includes("timed out")) {
|
|
841
|
+
const result = { payload: undefined, type: eventName, timedOut: true };
|
|
842
|
+
this.recordStep(name, "waitFor", start, { output: "timed out" });
|
|
843
|
+
return result;
|
|
844
|
+
}
|
|
845
|
+
this.recordStep(name, "waitFor", start, { error: msg });
|
|
846
|
+
throw e;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
async waitForUrl(eventName) {
|
|
850
|
+
const name = this.nextStep("callbackUrl", eventName);
|
|
851
|
+
return this.step.do(name, async () => {
|
|
852
|
+
if (!this.instanceId)
|
|
853
|
+
throw new Error("Instance ID not available — waitForUrl requires a running workflow");
|
|
854
|
+
const token = crypto.randomUUID();
|
|
855
|
+
if (this.env.MUG_DISPATCH) {
|
|
856
|
+
await this.env.MUG_DISPATCH.fetch("https://mug-dispatch/_internal/event-callback", {
|
|
857
|
+
method: "POST",
|
|
858
|
+
headers: this.internalHeaders(),
|
|
859
|
+
body: JSON.stringify({
|
|
860
|
+
token,
|
|
861
|
+
workspace: this.env.WORKSPACE_ID,
|
|
862
|
+
instanceId: this.instanceId,
|
|
863
|
+
eventType: eventName,
|
|
864
|
+
}),
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
return `https://api.mug.work/_callback/${token}`;
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
action(connectorName) {
|
|
871
|
+
const def = getConnector(connectorName);
|
|
872
|
+
if (!def)
|
|
873
|
+
throw new Error(`Connector "${connectorName}" not found`);
|
|
874
|
+
return new DurableConnectorHandle(this, def, connectorName);
|
|
875
|
+
}
|
|
876
|
+
async rollback(actionId) {
|
|
877
|
+
const rows = await this.query("_mug_ops", `SELECT * FROM actions WHERE id = ?`, [actionId]);
|
|
878
|
+
if (rows.length === 0)
|
|
879
|
+
throw new Error(`Action "${actionId}" not found`);
|
|
880
|
+
const original = rows[0];
|
|
881
|
+
if (original.rolled_back === 1) {
|
|
882
|
+
throw new Error(`Action ${actionId} has already been rolled back.`);
|
|
883
|
+
}
|
|
884
|
+
if (original.operation === "read") {
|
|
885
|
+
throw new Error(`Cannot rollback a read action.`);
|
|
886
|
+
}
|
|
887
|
+
if (!original.before_snapshot && original.operation !== "create") {
|
|
888
|
+
throw new Error(`Action ${actionId} has no before-snapshot — cannot rollback.`);
|
|
889
|
+
}
|
|
890
|
+
const connectorName = original.connector;
|
|
891
|
+
const tableName = original.table_name;
|
|
892
|
+
const recordId = original.record_id;
|
|
893
|
+
const snapshot = original.before_snapshot ? JSON.parse(original.before_snapshot) : null;
|
|
894
|
+
const confirmed = original.after_confirmed ? JSON.parse(original.after_confirmed) : null;
|
|
895
|
+
let result;
|
|
896
|
+
const handle = this.action(connectorName);
|
|
897
|
+
switch (original.operation) {
|
|
898
|
+
case "create": {
|
|
899
|
+
const createdId = confirmed?.id ?? recordId;
|
|
900
|
+
if (!createdId)
|
|
901
|
+
throw new Error(`Cannot rollback create — no record ID available.`);
|
|
902
|
+
result = await handle.delete(tableName, createdId);
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
case "update":
|
|
906
|
+
result = await handle.update(tableName, recordId, snapshot);
|
|
907
|
+
break;
|
|
908
|
+
case "delete":
|
|
909
|
+
result = await handle.create(tableName, snapshot);
|
|
910
|
+
break;
|
|
911
|
+
case "upsert":
|
|
912
|
+
if (snapshot) {
|
|
913
|
+
result = await handle.update(tableName, recordId, snapshot);
|
|
914
|
+
}
|
|
915
|
+
else {
|
|
916
|
+
result = await handle.delete(tableName, recordId);
|
|
917
|
+
}
|
|
918
|
+
break;
|
|
919
|
+
default:
|
|
920
|
+
throw new Error(`Cannot rollback operation type "${original.operation}".`);
|
|
921
|
+
}
|
|
922
|
+
await this.exec("_mug_ops", `UPDATE actions SET rolled_back = 1, rolled_back_at = ?, rollback_action_id = ? WHERE id = ?`, [new Date().toISOString(), result.operationId, actionId]);
|
|
923
|
+
return result;
|
|
924
|
+
}
|
|
925
|
+
async rollbackRun(workflowRunId) {
|
|
926
|
+
const actions = await this.query("_mug_ops", `SELECT id FROM actions WHERE workflow_run_id = ? AND operation != 'read' AND rolled_back = 0 ORDER BY created_at DESC`, [workflowRunId]);
|
|
927
|
+
const rolledBack = [];
|
|
928
|
+
const failed = [];
|
|
929
|
+
for (const action of actions) {
|
|
930
|
+
try {
|
|
931
|
+
const result = await this.rollback(action.id);
|
|
932
|
+
rolledBack.push(result);
|
|
933
|
+
}
|
|
934
|
+
catch (e) {
|
|
935
|
+
failed.push({ actionId: action.id, error: e.message });
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return { rolledBack, failed };
|
|
939
|
+
}
|
|
940
|
+
/** @internal */
|
|
941
|
+
_checkOpsCap() {
|
|
942
|
+
this.operationCount++;
|
|
943
|
+
if (this.operationCount > this.maxOperations) {
|
|
944
|
+
throw new Error(`Workflow exceeded max operations per run (${this.maxOperations}). ` +
|
|
945
|
+
`Adjust maxOperations in workflow config to allow more.`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
/** @internal */
|
|
949
|
+
_makeSourceCtx(connectorName) {
|
|
950
|
+
const env = this.env;
|
|
951
|
+
return {
|
|
952
|
+
credential: async (name) => {
|
|
953
|
+
if (env.MUG_DISPATCH && env.MUG_INTERNAL_SECRET) {
|
|
954
|
+
const provider = name ?? connectorName;
|
|
955
|
+
const res = await env.MUG_DISPATCH.fetch(`https://mug-dispatch/credential/${env.WORKSPACE_ID}/${provider}`, { headers: { "X-Mug-Internal": env.MUG_INTERNAL_SECRET } });
|
|
956
|
+
if (res.ok) {
|
|
957
|
+
const data = (await res.json());
|
|
958
|
+
return data.access_token;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
throw new Error(`No credentials for "${connectorName}"`);
|
|
962
|
+
},
|
|
963
|
+
fetch: (url, init) => {
|
|
964
|
+
if (url.includes("api.mug.work") && env.MUG_DISPATCH && env.MUG_INTERNAL_SECRET) {
|
|
965
|
+
const proxyUrl = url.replace(/https?:\/\/api\.mug\.work/, "https://mug-dispatch");
|
|
966
|
+
const headers = new Headers(init?.headers);
|
|
967
|
+
if (!headers.has("X-Mug-Internal"))
|
|
968
|
+
headers.set("X-Mug-Internal", env.MUG_INTERNAL_SECRET);
|
|
969
|
+
return env.MUG_DISPATCH.fetch(proxyUrl, { ...init, headers });
|
|
970
|
+
}
|
|
971
|
+
return fetch(url, init);
|
|
972
|
+
},
|
|
973
|
+
lastSync: null,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
/** @internal */
|
|
977
|
+
_doStep(stepName, fn) {
|
|
978
|
+
return this.step.do(stepName, fn);
|
|
979
|
+
}
|
|
980
|
+
async http(url, options) {
|
|
981
|
+
const method = (options?.method ?? "GET").toUpperCase();
|
|
982
|
+
const name = this.nextStep("http", new URL(url).hostname);
|
|
983
|
+
const start = Date.now();
|
|
984
|
+
const timeout = options?.timeout ?? 30000;
|
|
985
|
+
const throwOnError = options?.throwOnError !== false;
|
|
986
|
+
const retryConfig = options?.retry;
|
|
987
|
+
const shouldRetry = retryConfig !== false;
|
|
988
|
+
const maxAttempts = shouldRetry && typeof retryConfig === "object" && retryConfig?.attempts
|
|
989
|
+
? retryConfig.attempts
|
|
990
|
+
: (shouldRetry ? 3 : 1);
|
|
991
|
+
try {
|
|
992
|
+
const result = await this.step.do(name, async () => {
|
|
993
|
+
const headers = { ...options?.headers };
|
|
994
|
+
let bodyStr;
|
|
995
|
+
if (options?.body != null) {
|
|
996
|
+
bodyStr = typeof options.body === "string" ? options.body : JSON.stringify(options.body);
|
|
997
|
+
if (!headers["Content-Type"] && !headers["content-type"]) {
|
|
998
|
+
headers["Content-Type"] = "application/json";
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
if (options?.sign) {
|
|
1002
|
+
const secretValue = this.secret(options.sign.secret);
|
|
1003
|
+
const encoder = new TextEncoder();
|
|
1004
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secretValue), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
1005
|
+
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(bodyStr ?? ""));
|
|
1006
|
+
const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
1007
|
+
headers[options.sign.header ?? "X-Hub-Signature-256"] = `sha256=${hex}`;
|
|
1008
|
+
}
|
|
1009
|
+
// Route api.mug.work requests through MUG_DISPATCH service binding to avoid
|
|
1010
|
+
// self-referential fetch (CF error 1019/522) in Workers for Platforms tenants
|
|
1011
|
+
const dispatchBinding = this.env.MUG_DISPATCH;
|
|
1012
|
+
const internalSecret = this.env.MUG_INTERNAL_SECRET;
|
|
1013
|
+
const isDispatchUrl = url.includes("api.mug.work");
|
|
1014
|
+
const useServiceBinding = isDispatchUrl && dispatchBinding && internalSecret;
|
|
1015
|
+
let lastError;
|
|
1016
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
1017
|
+
try {
|
|
1018
|
+
const controller = new AbortController();
|
|
1019
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
1020
|
+
let fetchUrl = url;
|
|
1021
|
+
if (useServiceBinding) {
|
|
1022
|
+
fetchUrl = url.replace(/https?:\/\/api\.mug\.work/, "https://mug-dispatch");
|
|
1023
|
+
if (!headers["X-Mug-Internal"])
|
|
1024
|
+
headers["X-Mug-Internal"] = internalSecret;
|
|
1025
|
+
}
|
|
1026
|
+
const res = useServiceBinding
|
|
1027
|
+
? await dispatchBinding.fetch(fetchUrl, { method, headers, body: bodyStr, signal: controller.signal })
|
|
1028
|
+
: await fetch(url, { method, headers, body: bodyStr, signal: controller.signal });
|
|
1029
|
+
clearTimeout(timer);
|
|
1030
|
+
const resHeaders = {};
|
|
1031
|
+
res.headers.forEach((v, k) => { resHeaders[k] = v; });
|
|
1032
|
+
const resBody = await res.text();
|
|
1033
|
+
let json = null;
|
|
1034
|
+
const ct = resHeaders["content-type"] ?? "";
|
|
1035
|
+
if (ct.includes("json")) {
|
|
1036
|
+
try {
|
|
1037
|
+
json = JSON.parse(resBody);
|
|
1038
|
+
}
|
|
1039
|
+
catch { }
|
|
1040
|
+
}
|
|
1041
|
+
const httpResult = {
|
|
1042
|
+
status: res.status,
|
|
1043
|
+
headers: resHeaders,
|
|
1044
|
+
body: resBody,
|
|
1045
|
+
json,
|
|
1046
|
+
ok: res.status >= 200 && res.status < 300,
|
|
1047
|
+
};
|
|
1048
|
+
if (res.status === 429 && shouldRetry && attempt < maxAttempts) {
|
|
1049
|
+
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 30000);
|
|
1050
|
+
await new Promise(r => setTimeout(r, delay));
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
return httpResult;
|
|
1054
|
+
}
|
|
1055
|
+
catch (e) {
|
|
1056
|
+
lastError = e;
|
|
1057
|
+
const isRetryable = lastError.name === "AbortError" || lastError.message?.includes("fetch failed");
|
|
1058
|
+
if (isRetryable && shouldRetry && attempt < maxAttempts) {
|
|
1059
|
+
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 30000);
|
|
1060
|
+
await new Promise(r => setTimeout(r, delay));
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
throw lastError;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
throw lastError ?? new Error("ctx.http() failed after retries");
|
|
1067
|
+
});
|
|
1068
|
+
await this.meterIncrement("operations", 1, `workflow:http-${method.toLowerCase()}`);
|
|
1069
|
+
this.recordStep(name, "http", start, {
|
|
1070
|
+
input: { method, url },
|
|
1071
|
+
output: `${result.status} ${result.body.slice(0, 200)}`,
|
|
1072
|
+
});
|
|
1073
|
+
if (!result.ok && throwOnError) {
|
|
1074
|
+
throw new HttpError(result);
|
|
1075
|
+
}
|
|
1076
|
+
return result;
|
|
1077
|
+
}
|
|
1078
|
+
catch (e) {
|
|
1079
|
+
if (e instanceof HttpError) {
|
|
1080
|
+
this.recordStep(name, "http", start, {
|
|
1081
|
+
input: { method, url },
|
|
1082
|
+
output: `${e.status} (error thrown)`,
|
|
1083
|
+
});
|
|
1084
|
+
throw e;
|
|
1085
|
+
}
|
|
1086
|
+
this.recordStep(name, "http", start, {
|
|
1087
|
+
input: { method, url },
|
|
1088
|
+
error: e.message,
|
|
1089
|
+
});
|
|
1090
|
+
throw e;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
class DurableConnectorHandle {
|
|
1095
|
+
wfCtx;
|
|
1096
|
+
def;
|
|
1097
|
+
connectorName;
|
|
1098
|
+
constructor(wfCtx, def, connectorName) {
|
|
1099
|
+
this.wfCtx = wfCtx;
|
|
1100
|
+
this.def = def;
|
|
1101
|
+
this.connectorName = connectorName;
|
|
1102
|
+
}
|
|
1103
|
+
resolveTable(tableName) {
|
|
1104
|
+
const table = this.def.tables.find((t) => t.name === tableName);
|
|
1105
|
+
if (!table)
|
|
1106
|
+
throw new Error(`Table "${tableName}" not found in connector "${this.connectorName}"`);
|
|
1107
|
+
return table;
|
|
1108
|
+
}
|
|
1109
|
+
async read(tableName, recordId) {
|
|
1110
|
+
this.wfCtx._checkOpsCap();
|
|
1111
|
+
const table = this.resolveTable(tableName);
|
|
1112
|
+
if (!table.get)
|
|
1113
|
+
throw new Error(`Connector "${this.connectorName}" table "${tableName}" has no get() method`);
|
|
1114
|
+
const stepName = `action.read-${this.connectorName}.${tableName}-${Date.now()}`;
|
|
1115
|
+
const start = Date.now();
|
|
1116
|
+
const operationId = `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1117
|
+
const sourceCtx = this.wfCtx._makeSourceCtx(this.connectorName);
|
|
1118
|
+
const getFn = table.get;
|
|
1119
|
+
const data = await this.wfCtx._doStep(stepName, async () => {
|
|
1120
|
+
return (await getFn(sourceCtx, recordId)) ?? {};
|
|
1121
|
+
});
|
|
1122
|
+
return { connector: this.connectorName, table: tableName, operation: "read", recordId, data, operationId };
|
|
1123
|
+
}
|
|
1124
|
+
async create(tableName, fields) {
|
|
1125
|
+
this.wfCtx._checkOpsCap();
|
|
1126
|
+
const table = this.resolveTable(tableName);
|
|
1127
|
+
if (!table.actions?.create)
|
|
1128
|
+
throw new Error(`Connector "${this.connectorName}" table "${tableName}" has no create action`);
|
|
1129
|
+
const stepName = `action.create-${this.connectorName}.${tableName}-${Date.now()}`;
|
|
1130
|
+
const operationId = `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1131
|
+
const sourceCtx = this.wfCtx._makeSourceCtx(this.connectorName);
|
|
1132
|
+
const createFn = table.actions.create;
|
|
1133
|
+
const data = await this.wfCtx._doStep(stepName, async () => {
|
|
1134
|
+
return await createFn(sourceCtx, fields);
|
|
1135
|
+
});
|
|
1136
|
+
return { connector: this.connectorName, table: tableName, operation: "create", data, operationId };
|
|
1137
|
+
}
|
|
1138
|
+
async update(tableName, recordId, fields) {
|
|
1139
|
+
this.wfCtx._checkOpsCap();
|
|
1140
|
+
const table = this.resolveTable(tableName);
|
|
1141
|
+
if (!table.actions?.update)
|
|
1142
|
+
throw new Error(`Connector "${this.connectorName}" table "${tableName}" has no update action`);
|
|
1143
|
+
const stepName = `action.update-${this.connectorName}.${tableName}-${Date.now()}`;
|
|
1144
|
+
const operationId = `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1145
|
+
const sourceCtx = this.wfCtx._makeSourceCtx(this.connectorName);
|
|
1146
|
+
const getFn = table.get;
|
|
1147
|
+
const updateFn = table.actions.update;
|
|
1148
|
+
const result = await this.wfCtx._doStep(stepName, async () => {
|
|
1149
|
+
let snapshot;
|
|
1150
|
+
if (getFn) {
|
|
1151
|
+
snapshot = (await getFn(sourceCtx, recordId)) ?? undefined;
|
|
1152
|
+
}
|
|
1153
|
+
const data = await updateFn(sourceCtx, recordId, fields);
|
|
1154
|
+
return { data, snapshot };
|
|
1155
|
+
});
|
|
1156
|
+
return { connector: this.connectorName, table: tableName, operation: "update", recordId, data: result.data, snapshot: result.snapshot, operationId };
|
|
1157
|
+
}
|
|
1158
|
+
async delete(tableName, recordId) {
|
|
1159
|
+
this.wfCtx._checkOpsCap();
|
|
1160
|
+
const table = this.resolveTable(tableName);
|
|
1161
|
+
if (!table.actions?.delete)
|
|
1162
|
+
throw new Error(`Connector "${this.connectorName}" table "${tableName}" has no delete action`);
|
|
1163
|
+
const stepName = `action.delete-${this.connectorName}.${tableName}-${Date.now()}`;
|
|
1164
|
+
const operationId = `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1165
|
+
const sourceCtx = this.wfCtx._makeSourceCtx(this.connectorName);
|
|
1166
|
+
const getFn = table.get;
|
|
1167
|
+
const deleteFn = table.actions.delete;
|
|
1168
|
+
const result = await this.wfCtx._doStep(stepName, async () => {
|
|
1169
|
+
let snapshot;
|
|
1170
|
+
if (getFn) {
|
|
1171
|
+
snapshot = (await getFn(sourceCtx, recordId)) ?? undefined;
|
|
1172
|
+
}
|
|
1173
|
+
const data = await deleteFn(sourceCtx, recordId);
|
|
1174
|
+
return { data, snapshot };
|
|
1175
|
+
});
|
|
1176
|
+
return { connector: this.connectorName, table: tableName, operation: "delete", recordId, data: result.data, snapshot: result.snapshot, operationId };
|
|
1177
|
+
}
|
|
1178
|
+
async upsert(tableName, recordId, fields) {
|
|
1179
|
+
this.wfCtx._checkOpsCap();
|
|
1180
|
+
const table = this.resolveTable(tableName);
|
|
1181
|
+
if (!table.actions?.upsert)
|
|
1182
|
+
throw new Error(`Connector "${this.connectorName}" table "${tableName}" has no upsert action`);
|
|
1183
|
+
const stepName = `action.upsert-${this.connectorName}.${tableName}-${Date.now()}`;
|
|
1184
|
+
const operationId = `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1185
|
+
const sourceCtx = this.wfCtx._makeSourceCtx(this.connectorName);
|
|
1186
|
+
const getFn = table.get;
|
|
1187
|
+
const upsertFn = table.actions.upsert;
|
|
1188
|
+
const result = await this.wfCtx._doStep(stepName, async () => {
|
|
1189
|
+
let snapshot;
|
|
1190
|
+
if (getFn) {
|
|
1191
|
+
snapshot = (await getFn(sourceCtx, recordId)) ?? undefined;
|
|
1192
|
+
}
|
|
1193
|
+
const data = await upsertFn(sourceCtx, recordId, fields);
|
|
1194
|
+
return { data, snapshot };
|
|
1195
|
+
});
|
|
1196
|
+
return { connector: this.connectorName, table: tableName, operation: "upsert", recordId, data: result.data, snapshot: result.snapshot, operationId };
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
export class MugWorkflow extends WorkflowEntrypoint {
|
|
1200
|
+
async run(event, step) {
|
|
1201
|
+
const workflowName = event.payload.workflow;
|
|
1202
|
+
const def = getWorkflow(workflowName);
|
|
1203
|
+
if (!def)
|
|
1204
|
+
throw new Error(`Workflow "${workflowName}" not registered`);
|
|
1205
|
+
const workspace = this.env.WORKSPACE_ID;
|
|
1206
|
+
const runId = `${workspace}-${workflowName}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1207
|
+
const startedAt = new Date();
|
|
1208
|
+
const ctx = new DurableWorkflowContext(this.env, step, def.options?.maxOperations);
|
|
1209
|
+
ctx.changesetId = runId;
|
|
1210
|
+
ctx.changesetSource = `workflow:${workflowName}`;
|
|
1211
|
+
ctx.instanceId = event.instanceId;
|
|
1212
|
+
ctx.params = event.payload;
|
|
1213
|
+
let result;
|
|
1214
|
+
let error;
|
|
1215
|
+
let status = "complete";
|
|
1216
|
+
try {
|
|
1217
|
+
result = await def.handler(ctx);
|
|
1218
|
+
}
|
|
1219
|
+
catch (e) {
|
|
1220
|
+
error = e.message;
|
|
1221
|
+
status = "errored";
|
|
1222
|
+
}
|
|
1223
|
+
const completedAt = new Date();
|
|
1224
|
+
const runLog = {
|
|
1225
|
+
id: runId,
|
|
1226
|
+
workflow: workflowName,
|
|
1227
|
+
status,
|
|
1228
|
+
started_at: startedAt.toISOString(),
|
|
1229
|
+
completed_at: completedAt.toISOString(),
|
|
1230
|
+
duration_ms: completedAt.getTime() - startedAt.getTime(),
|
|
1231
|
+
params: JSON.stringify(event.payload),
|
|
1232
|
+
result: result != null ? JSON.stringify(result) : undefined,
|
|
1233
|
+
error,
|
|
1234
|
+
steps: ctx.steps,
|
|
1235
|
+
};
|
|
1236
|
+
await step.do("ops-log", async () => {
|
|
1237
|
+
const res = await fetch(`https://api.mug.work/ops/${workspace}/log-run`, {
|
|
1238
|
+
method: "POST",
|
|
1239
|
+
body: JSON.stringify(runLog),
|
|
1240
|
+
headers: {
|
|
1241
|
+
"Content-Type": "application/json",
|
|
1242
|
+
"X-Mug-Internal": this.env.MUG_INTERNAL_SECRET,
|
|
1243
|
+
},
|
|
1244
|
+
});
|
|
1245
|
+
if (!res.ok)
|
|
1246
|
+
console.error(`[ops] Log failed: ${res.status}`);
|
|
1247
|
+
});
|
|
1248
|
+
if (error)
|
|
1249
|
+
throw new Error(error);
|
|
1250
|
+
return result;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
export default {
|
|
1254
|
+
async fetch(request, env) {
|
|
1255
|
+
const url = new URL(request.url);
|
|
1256
|
+
if (url.pathname === "/slack-options" && request.method === "POST") {
|
|
1257
|
+
const body = await request.json();
|
|
1258
|
+
if (!env.MUG_DATA)
|
|
1259
|
+
return Response.json({ options: [] });
|
|
1260
|
+
const internalHeaders = {
|
|
1261
|
+
"Content-Type": "application/json",
|
|
1262
|
+
"X-Mug-Internal": env.MUG_INTERNAL_SECRET ?? "",
|
|
1263
|
+
};
|
|
1264
|
+
const res = await env.MUG_DATA.fetch(`https://mug-data/workspace/${env.WORKSPACE_ID}/db/${body.database}/query`, {
|
|
1265
|
+
method: "POST",
|
|
1266
|
+
body: JSON.stringify({ sql: body.query, params: body.params }),
|
|
1267
|
+
headers: internalHeaders,
|
|
1268
|
+
});
|
|
1269
|
+
const data = (await res.json());
|
|
1270
|
+
const options = (data.rows ?? []).map((row) => {
|
|
1271
|
+
const values = Object.values(row);
|
|
1272
|
+
const text = String(values[0] ?? "");
|
|
1273
|
+
const value = String(values[1] ?? values[0] ?? "");
|
|
1274
|
+
return { text: { type: "plain_text", text }, value };
|
|
1275
|
+
});
|
|
1276
|
+
return Response.json({ options });
|
|
1277
|
+
}
|
|
1278
|
+
if (url.pathname === "/create-workflow" && request.method === "POST") {
|
|
1279
|
+
const body = await request.json();
|
|
1280
|
+
const workflowName = body.workflow;
|
|
1281
|
+
const workspace = env.WORKSPACE_ID;
|
|
1282
|
+
const instanceId = `${workspace}-${workflowName}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1283
|
+
const wf = env.WORKFLOWS;
|
|
1284
|
+
const instance = await wf.create({
|
|
1285
|
+
id: instanceId,
|
|
1286
|
+
params: { workspace, workflow: workflowName, ...body },
|
|
1287
|
+
});
|
|
1288
|
+
return Response.json({
|
|
1289
|
+
status: "created",
|
|
1290
|
+
instanceId: await instance.id,
|
|
1291
|
+
workspace,
|
|
1292
|
+
workflow: workflowName,
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
return Response.json({ error: "Not found" }, { status: 404 });
|
|
1296
|
+
},
|
|
1297
|
+
};
|