@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,1008 @@
|
|
|
1
|
+
import { WorkspaceContext } from "./context.js";
|
|
2
|
+
import { getConnector } from "./source.js";
|
|
3
|
+
const OPS_DB = "_mug_ops";
|
|
4
|
+
export class HttpError extends Error {
|
|
5
|
+
status;
|
|
6
|
+
result;
|
|
7
|
+
constructor(result) {
|
|
8
|
+
super(`HTTP ${result.status}: ${result.body.slice(0, 200)}`);
|
|
9
|
+
this.name = "HttpError";
|
|
10
|
+
this.status = result.status;
|
|
11
|
+
this.result = result;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const DEFAULT_MAX_OPERATIONS = 100;
|
|
15
|
+
function truncate(value, maxLen = 4096) {
|
|
16
|
+
const s = JSON.stringify(value);
|
|
17
|
+
return s.length > maxLen ? s.slice(0, maxLen) + "…" : s;
|
|
18
|
+
}
|
|
19
|
+
function isBillableStep(type) {
|
|
20
|
+
return type === "exec" || type === "http" || type.startsWith("action.");
|
|
21
|
+
}
|
|
22
|
+
export class WorkflowContext {
|
|
23
|
+
ctx;
|
|
24
|
+
env;
|
|
25
|
+
stepCounter = 0;
|
|
26
|
+
workflowBilling;
|
|
27
|
+
operationCount = 0;
|
|
28
|
+
maxOperations;
|
|
29
|
+
steps = [];
|
|
30
|
+
params = {};
|
|
31
|
+
changesetId;
|
|
32
|
+
changesetSource;
|
|
33
|
+
instanceId;
|
|
34
|
+
_webhookResponse;
|
|
35
|
+
get isDemo() {
|
|
36
|
+
return this.params._demo === true;
|
|
37
|
+
}
|
|
38
|
+
get demoNotify() {
|
|
39
|
+
return this.params._demoNotify ?? null;
|
|
40
|
+
}
|
|
41
|
+
resolveDemoRecipient(channel, originalTo) {
|
|
42
|
+
const cfg = this.demoNotify;
|
|
43
|
+
if (!cfg)
|
|
44
|
+
return originalTo;
|
|
45
|
+
if (cfg.overrides?.[channel])
|
|
46
|
+
return cfg.overrides[channel];
|
|
47
|
+
switch (cfg.mode) {
|
|
48
|
+
case "off":
|
|
49
|
+
return null;
|
|
50
|
+
case "demo-user": {
|
|
51
|
+
const isEmail = cfg.identity.includes("@");
|
|
52
|
+
if (channel === "email" && isEmail)
|
|
53
|
+
return cfg.identity;
|
|
54
|
+
if (channel === "sms" && !isEmail)
|
|
55
|
+
return cfg.identity;
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
case "dev": {
|
|
59
|
+
if (channel === "email" && cfg.devEmail)
|
|
60
|
+
return cfg.devEmail;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
default:
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
constructor(env, options) {
|
|
68
|
+
this.env = env;
|
|
69
|
+
this.ctx = new WorkspaceContext(env);
|
|
70
|
+
this.workflowBilling = options?.billing;
|
|
71
|
+
this.maxOperations = options?.maxOperations ?? DEFAULT_MAX_OPERATIONS;
|
|
72
|
+
}
|
|
73
|
+
secret(name) {
|
|
74
|
+
const val = this.env[name];
|
|
75
|
+
if (typeof val !== "string")
|
|
76
|
+
throw new Error(`Secret "${name}" not found`);
|
|
77
|
+
return val;
|
|
78
|
+
}
|
|
79
|
+
/** @internal */
|
|
80
|
+
_nextStep(type, target) {
|
|
81
|
+
this.stepCounter++;
|
|
82
|
+
return `${type}-${target}-${this.stepCounter}`;
|
|
83
|
+
}
|
|
84
|
+
/** @internal */
|
|
85
|
+
_recordStep(name, type, startedAt, opts) {
|
|
86
|
+
const completedAt = Date.now();
|
|
87
|
+
const record = {
|
|
88
|
+
name,
|
|
89
|
+
type,
|
|
90
|
+
billable: isBillableStep(type),
|
|
91
|
+
startedAt,
|
|
92
|
+
completedAt,
|
|
93
|
+
durationMs: completedAt - startedAt,
|
|
94
|
+
};
|
|
95
|
+
if (opts?.input != null)
|
|
96
|
+
record.input = truncate(opts.input);
|
|
97
|
+
if (opts?.output != null)
|
|
98
|
+
record.output = truncate(opts.output);
|
|
99
|
+
if (opts?.error)
|
|
100
|
+
record.error = opts.error;
|
|
101
|
+
if (opts?.tokensUsed)
|
|
102
|
+
record.tokensUsed = opts.tokensUsed;
|
|
103
|
+
this.steps.push(record);
|
|
104
|
+
return record;
|
|
105
|
+
}
|
|
106
|
+
async query(database, sql, params) {
|
|
107
|
+
const name = this._nextStep("query", database);
|
|
108
|
+
const start = Date.now();
|
|
109
|
+
try {
|
|
110
|
+
const rows = await this.ctx.query(database, sql, params);
|
|
111
|
+
this._recordStep(name, "query", start, { input: { sql, params }, output: `${rows.length} rows` });
|
|
112
|
+
return rows;
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
this._recordStep(name, "query", start, { input: { sql, params }, error: e.message });
|
|
116
|
+
throw e;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async exec(database, sql, params) {
|
|
120
|
+
const name = this._nextStep("exec", database);
|
|
121
|
+
const start = Date.now();
|
|
122
|
+
const changeset = this.changesetId
|
|
123
|
+
? { id: this.changesetId, source: this.changesetSource ?? "unknown" }
|
|
124
|
+
: undefined;
|
|
125
|
+
try {
|
|
126
|
+
const changes = await this.ctx.exec(database, sql, params, changeset);
|
|
127
|
+
this._recordStep(name, "exec", start, { input: { sql, params }, output: `${changes} changes` });
|
|
128
|
+
return changes;
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
this._recordStep(name, "exec", start, { input: { sql, params }, error: e.message });
|
|
132
|
+
throw e;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async ai(model, options) {
|
|
136
|
+
const name = this._nextStep("ai", model);
|
|
137
|
+
const start = Date.now();
|
|
138
|
+
const opts = this.workflowBilling && !options.billing
|
|
139
|
+
? { ...options, billing: this.workflowBilling }
|
|
140
|
+
: options;
|
|
141
|
+
try {
|
|
142
|
+
const result = await this.ctx.ai(model, opts);
|
|
143
|
+
this._recordStep(name, "ai", start, {
|
|
144
|
+
input: { prompt: options.prompt.slice(0, 200) },
|
|
145
|
+
output: result.text.slice(0, 200),
|
|
146
|
+
tokensUsed: result.usage.input_tokens + result.usage.output_tokens,
|
|
147
|
+
});
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
this._recordStep(name, "ai", start, { input: { prompt: options.prompt.slice(0, 200) }, error: e.message });
|
|
152
|
+
throw e;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async search(query, options) {
|
|
156
|
+
const name = this._nextStep("search", options?.source ?? "all");
|
|
157
|
+
const start = Date.now();
|
|
158
|
+
try {
|
|
159
|
+
const results = await this.ctx.search(query, options);
|
|
160
|
+
this._recordStep(name, "search", start, {
|
|
161
|
+
input: { query: query.slice(0, 200), source: options?.source },
|
|
162
|
+
output: `${results.length} results`,
|
|
163
|
+
});
|
|
164
|
+
return results;
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
this._recordStep(name, "search", start, {
|
|
168
|
+
input: { query: query.slice(0, 200) },
|
|
169
|
+
error: e.message,
|
|
170
|
+
});
|
|
171
|
+
throw e;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async ask(question, options) {
|
|
175
|
+
const name = this._nextStep("ask", options?.source ?? "all");
|
|
176
|
+
const start = Date.now();
|
|
177
|
+
try {
|
|
178
|
+
const result = await this.ctx.ask(question, options);
|
|
179
|
+
this._recordStep(name, "ask", start, {
|
|
180
|
+
input: { question: question.slice(0, 200), source: options?.source },
|
|
181
|
+
output: result.answer.slice(0, 200),
|
|
182
|
+
tokensUsed: result.usage.input_tokens + result.usage.output_tokens,
|
|
183
|
+
});
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
|
+
this._recordStep(name, "ask", start, {
|
|
188
|
+
input: { question: question.slice(0, 200) },
|
|
189
|
+
error: e.message,
|
|
190
|
+
});
|
|
191
|
+
throw e;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
get notify() {
|
|
195
|
+
return {
|
|
196
|
+
sms: (options) => this.sendNotification("sms", options),
|
|
197
|
+
email: (options) => this.sendNotification("email", options),
|
|
198
|
+
slack: (options) => this.sendNotification("slack", options),
|
|
199
|
+
channel: (name, options) => this.sendNotification(name, options),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
async sendNotification(channel, options) {
|
|
203
|
+
const name = this._nextStep("notify", channel);
|
|
204
|
+
const start = Date.now();
|
|
205
|
+
if (this.isDemo) {
|
|
206
|
+
const resolved = this.resolveDemoRecipient(channel, options.to);
|
|
207
|
+
if (resolved === null) {
|
|
208
|
+
const suppressed = `suppressed (demo mode: ${this.demoNotify?.mode ?? "off"})`;
|
|
209
|
+
this._recordStep(name, "notify", start, {
|
|
210
|
+
input: { to: options.to },
|
|
211
|
+
output: suppressed,
|
|
212
|
+
});
|
|
213
|
+
return "suppressed";
|
|
214
|
+
}
|
|
215
|
+
options = { ...options, to: resolved };
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
const status = await this.ctx.notify[channel](options);
|
|
219
|
+
this._recordStep(name, "notify", start, { input: { to: options.to }, output: status });
|
|
220
|
+
return status;
|
|
221
|
+
}
|
|
222
|
+
catch (e) {
|
|
223
|
+
this._recordStep(name, "notify", start, { input: { to: options.to }, error: e.message });
|
|
224
|
+
throw e;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
get slack() {
|
|
228
|
+
return {
|
|
229
|
+
updateMessage: (options) => this.slackUpdateMessage(options),
|
|
230
|
+
openModal: (options) => this.slackOpenModal(options),
|
|
231
|
+
updateModal: (options) => this.slackUpdateModal(options),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
async slackUpdateMessage(options) {
|
|
235
|
+
const name = this._nextStep("slack", "updateMessage");
|
|
236
|
+
const start = Date.now();
|
|
237
|
+
try {
|
|
238
|
+
await this.ctx.slackApiCall("chat.update", {
|
|
239
|
+
channel: options.channel,
|
|
240
|
+
ts: options.ts,
|
|
241
|
+
text: options.text ?? "",
|
|
242
|
+
...(options.blocks ? { blocks: options.blocks } : {}),
|
|
243
|
+
});
|
|
244
|
+
this._recordStep(name, "slack.updateMessage", start, { input: { channel: options.channel, ts: options.ts }, output: "updated" });
|
|
245
|
+
}
|
|
246
|
+
catch (e) {
|
|
247
|
+
this._recordStep(name, "slack.updateMessage", start, { input: { channel: options.channel, ts: options.ts }, error: e.message });
|
|
248
|
+
throw e;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async slackOpenModal(options) {
|
|
252
|
+
const name = this._nextStep("slack", "openModal");
|
|
253
|
+
const start = Date.now();
|
|
254
|
+
try {
|
|
255
|
+
await this.ctx.slackApiCall("views.open", {
|
|
256
|
+
trigger_id: options.triggerId,
|
|
257
|
+
view: options.view,
|
|
258
|
+
});
|
|
259
|
+
this._recordStep(name, "slack.openModal", start, { output: "opened" });
|
|
260
|
+
}
|
|
261
|
+
catch (e) {
|
|
262
|
+
this._recordStep(name, "slack.openModal", start, { error: e.message });
|
|
263
|
+
throw e;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
async slackUpdateModal(options) {
|
|
267
|
+
const name = this._nextStep("slack", "updateModal");
|
|
268
|
+
const start = Date.now();
|
|
269
|
+
try {
|
|
270
|
+
await this.ctx.slackApiCall("views.update", {
|
|
271
|
+
view_id: options.viewId,
|
|
272
|
+
view: options.view,
|
|
273
|
+
});
|
|
274
|
+
this._recordStep(name, "slack.updateModal", start, { input: { viewId: options.viewId }, output: "updated" });
|
|
275
|
+
}
|
|
276
|
+
catch (e) {
|
|
277
|
+
this._recordStep(name, "slack.updateModal", start, { input: { viewId: options.viewId }, error: e.message });
|
|
278
|
+
throw e;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async file(path) {
|
|
282
|
+
const name = this._nextStep("file", path);
|
|
283
|
+
const start = Date.now();
|
|
284
|
+
try {
|
|
285
|
+
const buffer = await this.ctx.file(path);
|
|
286
|
+
this._recordStep(name, "file", start, { input: { path }, output: `${buffer.byteLength} bytes` });
|
|
287
|
+
return buffer;
|
|
288
|
+
}
|
|
289
|
+
catch (e) {
|
|
290
|
+
this._recordStep(name, "file", start, { input: { path }, error: e.message });
|
|
291
|
+
throw e;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
async fileText(path) {
|
|
295
|
+
const name = this._nextStep("fileText", path);
|
|
296
|
+
const start = Date.now();
|
|
297
|
+
try {
|
|
298
|
+
const text = await this.ctx.fileText(path);
|
|
299
|
+
this._recordStep(name, "fileText", start, { input: { path }, output: `${text.length} chars` });
|
|
300
|
+
return text;
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
this._recordStep(name, "fileText", start, { input: { path }, error: e.message });
|
|
304
|
+
throw e;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
async embed(texts) {
|
|
308
|
+
if (texts.length === 0)
|
|
309
|
+
return [];
|
|
310
|
+
const name = this._nextStep("embed", `${texts.length} texts`);
|
|
311
|
+
const start = Date.now();
|
|
312
|
+
try {
|
|
313
|
+
const vectors = await this.ctx.embed(texts);
|
|
314
|
+
this._recordStep(name, "embed", start, { input: { count: texts.length }, output: `${vectors.length} vectors` });
|
|
315
|
+
return vectors;
|
|
316
|
+
}
|
|
317
|
+
catch (e) {
|
|
318
|
+
this._recordStep(name, "embed", start, { input: { count: texts.length }, error: e.message });
|
|
319
|
+
throw e;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
async slackApiCall(method, body) {
|
|
323
|
+
const name = this._nextStep("slack.api", method);
|
|
324
|
+
const start = Date.now();
|
|
325
|
+
try {
|
|
326
|
+
const data = await this.ctx.slackApiCall(method, body);
|
|
327
|
+
this._recordStep(name, "slack.api", start, { input: { method }, output: "ok" });
|
|
328
|
+
return data;
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
this._recordStep(name, "slack.api", start, { input: { method }, error: e.message });
|
|
332
|
+
throw e;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
surfaceUrl(surfaceId, path) {
|
|
336
|
+
return this.ctx.surfaceUrl(surfaceId, path);
|
|
337
|
+
}
|
|
338
|
+
async respond(body, status) {
|
|
339
|
+
if (this._webhookResponse)
|
|
340
|
+
return;
|
|
341
|
+
this._webhookResponse = { body, status: status ?? 200 };
|
|
342
|
+
}
|
|
343
|
+
async agent(name, options) {
|
|
344
|
+
const stepName = this._nextStep("agent", name);
|
|
345
|
+
const start = Date.now();
|
|
346
|
+
const sessionKey = options.sessionKey ?? `${this.changesetId ?? "run"}-${name}`;
|
|
347
|
+
try {
|
|
348
|
+
const res = await this.ctx.invokeAgent(name, {
|
|
349
|
+
goal: options.goal,
|
|
350
|
+
context: options.context,
|
|
351
|
+
sessionKey,
|
|
352
|
+
caps: options.caps,
|
|
353
|
+
});
|
|
354
|
+
if (res.status === "pending_approval" && res.pendingApproval) {
|
|
355
|
+
const pending = res.pendingApproval;
|
|
356
|
+
console.log(`[agent] "${name}" needs approval for ${pending.tool} — auto-approving in local dev`);
|
|
357
|
+
this._recordStep(stepName, "agent", start, {
|
|
358
|
+
input: { goal: options.goal.slice(0, 200) },
|
|
359
|
+
output: `pending approval: ${pending.tool} (auto-approved in dev)`,
|
|
360
|
+
});
|
|
361
|
+
const approveRes = await this.ctx.invokeAgent(name, {
|
|
362
|
+
goal: "__approve__",
|
|
363
|
+
context: { _approve: true, sessionKey },
|
|
364
|
+
sessionKey,
|
|
365
|
+
});
|
|
366
|
+
const duration = Date.now() - start;
|
|
367
|
+
return {
|
|
368
|
+
response: approveRes.response,
|
|
369
|
+
output: approveRes.output ?? res.output ?? undefined,
|
|
370
|
+
usage: { credits: (res.usage?.credits ?? 0) + (approveRes.usage?.credits ?? 0), turns: (res.usage?.turns ?? 0) + (approveRes.usage?.turns ?? 0), duration },
|
|
371
|
+
capped: approveRes.capped,
|
|
372
|
+
cappedReason: approveRes.cappedReason,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
const duration = Date.now() - start;
|
|
376
|
+
const result = {
|
|
377
|
+
response: res.response,
|
|
378
|
+
output: res.output ?? undefined,
|
|
379
|
+
usage: { credits: res.usage?.credits ?? 0, turns: res.usage?.turns ?? 0, duration },
|
|
380
|
+
capped: res.capped,
|
|
381
|
+
cappedReason: res.cappedReason,
|
|
382
|
+
};
|
|
383
|
+
this._recordStep(stepName, "agent", start, {
|
|
384
|
+
input: { goal: options.goal.slice(0, 200) },
|
|
385
|
+
output: res.response.slice(0, 200),
|
|
386
|
+
});
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
389
|
+
catch (e) {
|
|
390
|
+
this._recordStep(stepName, "agent", start, {
|
|
391
|
+
input: { goal: options.goal.slice(0, 200) },
|
|
392
|
+
error: e.message,
|
|
393
|
+
});
|
|
394
|
+
throw e;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async collect(options) {
|
|
398
|
+
const name = this._nextStep("collect", options.title);
|
|
399
|
+
const start = Date.now();
|
|
400
|
+
try {
|
|
401
|
+
const url = await this.ctx.collect(options);
|
|
402
|
+
this._recordStep(name, "collect", start, { input: { title: options.title }, output: url });
|
|
403
|
+
return url;
|
|
404
|
+
}
|
|
405
|
+
catch (e) {
|
|
406
|
+
this._recordStep(name, "collect", start, { input: { title: options.title }, error: e.message });
|
|
407
|
+
throw e;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
async waitFor(eventName, options) {
|
|
411
|
+
const name = this._nextStep("waitFor", eventName);
|
|
412
|
+
const start = Date.now();
|
|
413
|
+
console.log(`[waitFor] Waiting for "${eventName}" — in production, workflow pauses here (zero cost).`);
|
|
414
|
+
if (options?.message)
|
|
415
|
+
console.log(`[waitFor] ${options.message}`);
|
|
416
|
+
console.log(`[waitFor] Resolving immediately for local development.`);
|
|
417
|
+
const result = { payload: {}, type: eventName, timedOut: false };
|
|
418
|
+
this._recordStep(name, "waitFor", start, { output: "resolved (local dev)" });
|
|
419
|
+
return result;
|
|
420
|
+
}
|
|
421
|
+
async waitForUrl(eventName) {
|
|
422
|
+
return `http://localhost:8787/_event?type=${encodeURIComponent(eventName)}`;
|
|
423
|
+
}
|
|
424
|
+
async http(url, options) {
|
|
425
|
+
const method = (options?.method ?? "GET").toUpperCase();
|
|
426
|
+
const name = this._nextStep("http", new URL(url).hostname);
|
|
427
|
+
const start = Date.now();
|
|
428
|
+
const timeout = options?.timeout ?? 30000;
|
|
429
|
+
const throwOnError = options?.throwOnError !== false;
|
|
430
|
+
const retryConfig = options?.retry;
|
|
431
|
+
const shouldRetry = retryConfig !== false;
|
|
432
|
+
const maxAttempts = shouldRetry && typeof retryConfig === "object" && retryConfig?.attempts
|
|
433
|
+
? retryConfig.attempts
|
|
434
|
+
: (shouldRetry ? 3 : 1);
|
|
435
|
+
const headers = { ...options?.headers };
|
|
436
|
+
let bodyStr;
|
|
437
|
+
if (options?.body != null) {
|
|
438
|
+
bodyStr = typeof options.body === "string" ? options.body : JSON.stringify(options.body);
|
|
439
|
+
if (!headers["Content-Type"] && !headers["content-type"]) {
|
|
440
|
+
headers["Content-Type"] = "application/json";
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (options?.sign) {
|
|
444
|
+
const secretValue = this.secret(options.sign.secret);
|
|
445
|
+
const encoder = new TextEncoder();
|
|
446
|
+
const key = await crypto.subtle.importKey("raw", encoder.encode(secretValue), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
447
|
+
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(bodyStr ?? ""));
|
|
448
|
+
const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
449
|
+
headers[options.sign.header ?? "X-Hub-Signature-256"] = `sha256=${hex}`;
|
|
450
|
+
}
|
|
451
|
+
// Route api.mug.work requests through MUG_DISPATCH service binding to avoid
|
|
452
|
+
// self-referential fetch (CF error 1019/522) in Workers for Platforms tenants
|
|
453
|
+
const dispatchBinding = this.env.MUG_DISPATCH;
|
|
454
|
+
const internalSecret = this.env.MUG_INTERNAL_SECRET;
|
|
455
|
+
const isDispatchUrl = url.includes("api.mug.work");
|
|
456
|
+
const useServiceBinding = isDispatchUrl && dispatchBinding && internalSecret;
|
|
457
|
+
let lastError;
|
|
458
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
459
|
+
try {
|
|
460
|
+
const controller = new AbortController();
|
|
461
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
462
|
+
let fetchUrl = url;
|
|
463
|
+
if (useServiceBinding) {
|
|
464
|
+
fetchUrl = url.replace(/https?:\/\/api\.mug\.work/, "https://mug-dispatch");
|
|
465
|
+
if (!headers["X-Mug-Internal"])
|
|
466
|
+
headers["X-Mug-Internal"] = internalSecret;
|
|
467
|
+
}
|
|
468
|
+
const res = useServiceBinding
|
|
469
|
+
? await dispatchBinding.fetch(fetchUrl, { method, headers, body: bodyStr, signal: controller.signal })
|
|
470
|
+
: await fetch(url, { method, headers, body: bodyStr, signal: controller.signal });
|
|
471
|
+
clearTimeout(timer);
|
|
472
|
+
const resHeaders = {};
|
|
473
|
+
res.headers.forEach((v, k) => { resHeaders[k] = v; });
|
|
474
|
+
const resBody = await res.text();
|
|
475
|
+
let json = null;
|
|
476
|
+
const ct = resHeaders["content-type"] ?? "";
|
|
477
|
+
if (ct.includes("json")) {
|
|
478
|
+
try {
|
|
479
|
+
json = JSON.parse(resBody);
|
|
480
|
+
}
|
|
481
|
+
catch { }
|
|
482
|
+
}
|
|
483
|
+
const result = {
|
|
484
|
+
status: res.status,
|
|
485
|
+
headers: resHeaders,
|
|
486
|
+
body: resBody,
|
|
487
|
+
json,
|
|
488
|
+
ok: res.status >= 200 && res.status < 300,
|
|
489
|
+
};
|
|
490
|
+
if (res.status === 429 && shouldRetry && attempt < maxAttempts) {
|
|
491
|
+
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 30000);
|
|
492
|
+
console.log(`[http] ${method} ${url} → 429, retrying in ${delay}ms (attempt ${attempt}/${maxAttempts})`);
|
|
493
|
+
await new Promise(r => setTimeout(r, delay));
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
this._recordStep(name, "http", start, {
|
|
497
|
+
input: { method, url },
|
|
498
|
+
output: `${res.status} ${resBody.slice(0, 200)}`,
|
|
499
|
+
});
|
|
500
|
+
if (!result.ok && throwOnError) {
|
|
501
|
+
throw new HttpError(result);
|
|
502
|
+
}
|
|
503
|
+
return result;
|
|
504
|
+
}
|
|
505
|
+
catch (e) {
|
|
506
|
+
if (e instanceof HttpError)
|
|
507
|
+
throw e;
|
|
508
|
+
lastError = e;
|
|
509
|
+
const isRetryable = lastError.name === "AbortError" || lastError.message?.includes("fetch failed");
|
|
510
|
+
if (isRetryable && shouldRetry && attempt < maxAttempts) {
|
|
511
|
+
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 30000);
|
|
512
|
+
console.log(`[http] ${method} ${url} → ${lastError.message}, retrying in ${delay}ms (attempt ${attempt}/${maxAttempts})`);
|
|
513
|
+
await new Promise(r => setTimeout(r, delay));
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
this._recordStep(name, "http", start, {
|
|
517
|
+
input: { method, url },
|
|
518
|
+
error: lastError.message,
|
|
519
|
+
});
|
|
520
|
+
throw lastError;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
this._recordStep(name, "http", start, { input: { method, url }, error: lastError?.message ?? "max retries" });
|
|
524
|
+
throw lastError ?? new Error("ctx.http() failed after retries");
|
|
525
|
+
}
|
|
526
|
+
action(connectorName) {
|
|
527
|
+
const def = getConnector(connectorName);
|
|
528
|
+
if (!def)
|
|
529
|
+
throw new Error(`Connector "${connectorName}" not found`);
|
|
530
|
+
return new ConnectorHandle(this, def, connectorName);
|
|
531
|
+
}
|
|
532
|
+
async rollback(actionId) {
|
|
533
|
+
const rows = await this.ctx.query(OPS_DB, `SELECT * FROM actions WHERE id = ?`, [actionId]);
|
|
534
|
+
if (rows.length === 0)
|
|
535
|
+
throw new Error(`Action "${actionId}" not found`);
|
|
536
|
+
const original = rows[0];
|
|
537
|
+
if (original.rolled_back === 1) {
|
|
538
|
+
throw new Error(`Action ${actionId} has already been rolled back.`);
|
|
539
|
+
}
|
|
540
|
+
if (original.operation === "read") {
|
|
541
|
+
throw new Error(`Cannot rollback a read action.`);
|
|
542
|
+
}
|
|
543
|
+
if (!original.before_snapshot && original.operation !== "create") {
|
|
544
|
+
throw new Error(`Action ${actionId} has no before-snapshot — cannot rollback.`);
|
|
545
|
+
}
|
|
546
|
+
const connectorName = original.connector;
|
|
547
|
+
const tableName = original.table_name;
|
|
548
|
+
const recordId = original.record_id;
|
|
549
|
+
const snapshot = original.before_snapshot ? JSON.parse(original.before_snapshot) : null;
|
|
550
|
+
const confirmed = original.after_confirmed ? JSON.parse(original.after_confirmed) : null;
|
|
551
|
+
let result;
|
|
552
|
+
const handle = this.action(connectorName);
|
|
553
|
+
switch (original.operation) {
|
|
554
|
+
case "create": {
|
|
555
|
+
const createdId = confirmed?.id ?? recordId;
|
|
556
|
+
if (!createdId)
|
|
557
|
+
throw new Error(`Cannot rollback create — no record ID available.`);
|
|
558
|
+
result = await handle.delete(tableName, createdId);
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
case "update":
|
|
562
|
+
result = await handle.update(tableName, recordId, snapshot);
|
|
563
|
+
break;
|
|
564
|
+
case "delete":
|
|
565
|
+
result = await handle.create(tableName, snapshot);
|
|
566
|
+
break;
|
|
567
|
+
case "upsert":
|
|
568
|
+
if (snapshot) {
|
|
569
|
+
result = await handle.update(tableName, recordId, snapshot);
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
result = await handle.delete(tableName, recordId);
|
|
573
|
+
}
|
|
574
|
+
break;
|
|
575
|
+
default:
|
|
576
|
+
throw new Error(`Cannot rollback operation type "${original.operation}".`);
|
|
577
|
+
}
|
|
578
|
+
await this.ctx.exec(OPS_DB, `UPDATE actions SET rolled_back = 1, rolled_back_at = ?, rollback_action_id = ? WHERE id = ?`, [new Date().toISOString(), result.operationId, actionId]);
|
|
579
|
+
return result;
|
|
580
|
+
}
|
|
581
|
+
async rollbackRun(workflowRunId) {
|
|
582
|
+
const actions = await this.ctx.query(OPS_DB, `SELECT id FROM actions WHERE workflow_run_id = ? AND operation != 'read' AND rolled_back = 0 ORDER BY created_at DESC`, [workflowRunId]);
|
|
583
|
+
const rolledBack = [];
|
|
584
|
+
const failed = [];
|
|
585
|
+
for (const action of actions) {
|
|
586
|
+
try {
|
|
587
|
+
const result = await this.rollback(action.id);
|
|
588
|
+
rolledBack.push(result);
|
|
589
|
+
}
|
|
590
|
+
catch (e) {
|
|
591
|
+
failed.push({ actionId: action.id, error: e.message });
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return { rolledBack, failed };
|
|
595
|
+
}
|
|
596
|
+
/** @internal */
|
|
597
|
+
_checkOpsCap() {
|
|
598
|
+
this.operationCount++;
|
|
599
|
+
if (this.operationCount > this.maxOperations) {
|
|
600
|
+
throw new Error(`Workflow exceeded max operations per run (${this.maxOperations}). ` +
|
|
601
|
+
`Adjust maxOperations in workflow config to allow more.`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
/** @internal */
|
|
605
|
+
async _logAction(result, afterPayload) {
|
|
606
|
+
try {
|
|
607
|
+
await this.ctx.exec(OPS_DB, `CREATE TABLE IF NOT EXISTS actions (
|
|
608
|
+
id TEXT PRIMARY KEY, workflow_run_id TEXT NOT NULL, step_id TEXT,
|
|
609
|
+
connector TEXT NOT NULL, table_name TEXT NOT NULL, operation TEXT NOT NULL,
|
|
610
|
+
record_id TEXT, before_snapshot TEXT, after_payload TEXT, after_confirmed TEXT,
|
|
611
|
+
rolled_back INTEGER DEFAULT 0, rolled_back_at TEXT, rollback_action_id TEXT, created_at TEXT NOT NULL
|
|
612
|
+
)`);
|
|
613
|
+
await this.ctx.exec(OPS_DB, `INSERT INTO actions (id, workflow_run_id, connector, table_name, operation, record_id, before_snapshot, after_payload, after_confirmed, created_at)
|
|
614
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
615
|
+
result.operationId,
|
|
616
|
+
this.changesetId ?? "unknown",
|
|
617
|
+
result.connector,
|
|
618
|
+
result.table,
|
|
619
|
+
result.operation,
|
|
620
|
+
result.recordId ?? null,
|
|
621
|
+
result.snapshot ? JSON.stringify(result.snapshot) : null,
|
|
622
|
+
afterPayload ? JSON.stringify(afterPayload) : null,
|
|
623
|
+
JSON.stringify(result.data),
|
|
624
|
+
new Date().toISOString(),
|
|
625
|
+
]);
|
|
626
|
+
}
|
|
627
|
+
catch (e) {
|
|
628
|
+
console.error(`[ops] Failed to log action: ${e.message}`);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
/** @internal */
|
|
632
|
+
_makeSourceCtx(connectorName) {
|
|
633
|
+
const env = this.env;
|
|
634
|
+
const sources = JSON.parse(env.MUG_SOURCES || "{}");
|
|
635
|
+
const src = sources[connectorName];
|
|
636
|
+
return {
|
|
637
|
+
credential: async (name) => {
|
|
638
|
+
if (src?.auth?.value) {
|
|
639
|
+
const resolved = env[src.auth.value];
|
|
640
|
+
if (typeof resolved === "string")
|
|
641
|
+
return resolved;
|
|
642
|
+
return src.auth.value;
|
|
643
|
+
}
|
|
644
|
+
if (env.MUG_DISPATCH && env.MUG_INTERNAL_SECRET) {
|
|
645
|
+
const provider = name ?? connectorName;
|
|
646
|
+
const res = await env.MUG_DISPATCH.fetch(`https://mug-dispatch/credential/${env.WORKSPACE_ID}/${provider}`, { headers: { "X-Mug-Internal": env.MUG_INTERNAL_SECRET } });
|
|
647
|
+
if (res.ok) {
|
|
648
|
+
const data = (await res.json());
|
|
649
|
+
return data.access_token;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
throw new Error(`No credentials for "${connectorName}"`);
|
|
653
|
+
},
|
|
654
|
+
fetch: (url, init) => {
|
|
655
|
+
if (url.includes("api.mug.work") && env.MUG_DISPATCH && env.MUG_INTERNAL_SECRET) {
|
|
656
|
+
const proxyUrl = url.replace(/https?:\/\/api\.mug\.work/, "https://mug-dispatch");
|
|
657
|
+
const headers = new Headers(init?.headers);
|
|
658
|
+
if (!headers.has("X-Mug-Internal"))
|
|
659
|
+
headers.set("X-Mug-Internal", env.MUG_INTERNAL_SECRET);
|
|
660
|
+
return env.MUG_DISPATCH.fetch(proxyUrl, { ...init, headers });
|
|
661
|
+
}
|
|
662
|
+
return fetch(url, init);
|
|
663
|
+
},
|
|
664
|
+
lastSync: null,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
class ConnectorHandle {
|
|
669
|
+
wfCtx;
|
|
670
|
+
def;
|
|
671
|
+
connectorName;
|
|
672
|
+
constructor(wfCtx, def, connectorName) {
|
|
673
|
+
this.wfCtx = wfCtx;
|
|
674
|
+
this.def = def;
|
|
675
|
+
this.connectorName = connectorName;
|
|
676
|
+
}
|
|
677
|
+
resolveTable(tableName) {
|
|
678
|
+
const table = this.def.tables.find((t) => t.name === tableName);
|
|
679
|
+
if (!table)
|
|
680
|
+
throw new Error(`Table "${tableName}" not found in connector "${this.connectorName}"`);
|
|
681
|
+
return table;
|
|
682
|
+
}
|
|
683
|
+
async read(tableName, recordId) {
|
|
684
|
+
this.wfCtx._checkOpsCap();
|
|
685
|
+
const table = this.resolveTable(tableName);
|
|
686
|
+
if (!table.get)
|
|
687
|
+
throw new Error(`Connector "${this.connectorName}" table "${tableName}" has no get() method`);
|
|
688
|
+
const stepName = this.wfCtx._nextStep("action.read", `${this.connectorName}.${tableName}`);
|
|
689
|
+
const start = Date.now();
|
|
690
|
+
const operationId = `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
691
|
+
const sourceCtx = this.wfCtx._makeSourceCtx(this.connectorName);
|
|
692
|
+
try {
|
|
693
|
+
const data = (await table.get(sourceCtx, recordId)) ?? {};
|
|
694
|
+
this.wfCtx._recordStep(stepName, "action.read", start, {
|
|
695
|
+
input: { connector: this.connectorName, table: tableName, recordId },
|
|
696
|
+
output: `read ${recordId}`,
|
|
697
|
+
});
|
|
698
|
+
const result = { connector: this.connectorName, table: tableName, operation: "read", recordId, data, operationId };
|
|
699
|
+
await this.wfCtx._logAction(result);
|
|
700
|
+
return result;
|
|
701
|
+
}
|
|
702
|
+
catch (e) {
|
|
703
|
+
this.wfCtx._recordStep(stepName, "action.read", start, {
|
|
704
|
+
input: { connector: this.connectorName, table: tableName, recordId },
|
|
705
|
+
error: e.message,
|
|
706
|
+
});
|
|
707
|
+
throw e;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
async create(tableName, fields) {
|
|
711
|
+
this.wfCtx._checkOpsCap();
|
|
712
|
+
const table = this.resolveTable(tableName);
|
|
713
|
+
if (!table.actions?.create)
|
|
714
|
+
throw new Error(`Connector "${this.connectorName}" table "${tableName}" has no create action`);
|
|
715
|
+
const stepName = this.wfCtx._nextStep("action.create", `${this.connectorName}.${tableName}`);
|
|
716
|
+
const start = Date.now();
|
|
717
|
+
const operationId = `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
718
|
+
const sourceCtx = this.wfCtx._makeSourceCtx(this.connectorName);
|
|
719
|
+
try {
|
|
720
|
+
const data = await table.actions.create(sourceCtx, fields);
|
|
721
|
+
this.wfCtx._recordStep(stepName, "action.create", start, {
|
|
722
|
+
input: { connector: this.connectorName, table: tableName, fields },
|
|
723
|
+
output: `created`,
|
|
724
|
+
});
|
|
725
|
+
const result = { connector: this.connectorName, table: tableName, operation: "create", data, operationId };
|
|
726
|
+
await this.wfCtx._logAction(result, fields);
|
|
727
|
+
return result;
|
|
728
|
+
}
|
|
729
|
+
catch (e) {
|
|
730
|
+
this.wfCtx._recordStep(stepName, "action.create", start, {
|
|
731
|
+
input: { connector: this.connectorName, table: tableName },
|
|
732
|
+
error: e.message,
|
|
733
|
+
});
|
|
734
|
+
throw e;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
async update(tableName, recordId, fields) {
|
|
738
|
+
this.wfCtx._checkOpsCap();
|
|
739
|
+
const table = this.resolveTable(tableName);
|
|
740
|
+
if (!table.actions?.update)
|
|
741
|
+
throw new Error(`Connector "${this.connectorName}" table "${tableName}" has no update action`);
|
|
742
|
+
const stepName = this.wfCtx._nextStep("action.update", `${this.connectorName}.${tableName}`);
|
|
743
|
+
const start = Date.now();
|
|
744
|
+
const operationId = `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
745
|
+
const sourceCtx = this.wfCtx._makeSourceCtx(this.connectorName);
|
|
746
|
+
let snapshot;
|
|
747
|
+
if (table.get) {
|
|
748
|
+
snapshot = (await table.get(sourceCtx, recordId)) ?? undefined;
|
|
749
|
+
}
|
|
750
|
+
try {
|
|
751
|
+
const data = await table.actions.update(sourceCtx, recordId, fields);
|
|
752
|
+
this.wfCtx._recordStep(stepName, "action.update", start, {
|
|
753
|
+
input: { connector: this.connectorName, table: tableName, recordId, fields },
|
|
754
|
+
output: `updated ${recordId}`,
|
|
755
|
+
});
|
|
756
|
+
const result = { connector: this.connectorName, table: tableName, operation: "update", recordId, data, snapshot, operationId };
|
|
757
|
+
await this.wfCtx._logAction(result, fields);
|
|
758
|
+
return result;
|
|
759
|
+
}
|
|
760
|
+
catch (e) {
|
|
761
|
+
this.wfCtx._recordStep(stepName, "action.update", start, {
|
|
762
|
+
input: { connector: this.connectorName, table: tableName, recordId },
|
|
763
|
+
error: e.message,
|
|
764
|
+
});
|
|
765
|
+
throw e;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
async delete(tableName, recordId) {
|
|
769
|
+
this.wfCtx._checkOpsCap();
|
|
770
|
+
const table = this.resolveTable(tableName);
|
|
771
|
+
if (!table.actions?.delete)
|
|
772
|
+
throw new Error(`Connector "${this.connectorName}" table "${tableName}" has no delete action`);
|
|
773
|
+
const stepName = this.wfCtx._nextStep("action.delete", `${this.connectorName}.${tableName}`);
|
|
774
|
+
const start = Date.now();
|
|
775
|
+
const operationId = `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
776
|
+
const sourceCtx = this.wfCtx._makeSourceCtx(this.connectorName);
|
|
777
|
+
let snapshot;
|
|
778
|
+
if (table.get) {
|
|
779
|
+
snapshot = (await table.get(sourceCtx, recordId)) ?? undefined;
|
|
780
|
+
}
|
|
781
|
+
try {
|
|
782
|
+
const data = await table.actions.delete(sourceCtx, recordId);
|
|
783
|
+
this.wfCtx._recordStep(stepName, "action.delete", start, {
|
|
784
|
+
input: { connector: this.connectorName, table: tableName, recordId },
|
|
785
|
+
output: `deleted ${recordId}`,
|
|
786
|
+
});
|
|
787
|
+
const result = { connector: this.connectorName, table: tableName, operation: "delete", recordId, data, snapshot, operationId };
|
|
788
|
+
await this.wfCtx._logAction(result);
|
|
789
|
+
return result;
|
|
790
|
+
}
|
|
791
|
+
catch (e) {
|
|
792
|
+
this.wfCtx._recordStep(stepName, "action.delete", start, {
|
|
793
|
+
input: { connector: this.connectorName, table: tableName, recordId },
|
|
794
|
+
error: e.message,
|
|
795
|
+
});
|
|
796
|
+
throw e;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
async upsert(tableName, recordId, fields) {
|
|
800
|
+
this.wfCtx._checkOpsCap();
|
|
801
|
+
const table = this.resolveTable(tableName);
|
|
802
|
+
if (!table.actions?.upsert)
|
|
803
|
+
throw new Error(`Connector "${this.connectorName}" table "${tableName}" has no upsert action`);
|
|
804
|
+
const stepName = this.wfCtx._nextStep("action.upsert", `${this.connectorName}.${tableName}`);
|
|
805
|
+
const start = Date.now();
|
|
806
|
+
const operationId = `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
807
|
+
const sourceCtx = this.wfCtx._makeSourceCtx(this.connectorName);
|
|
808
|
+
let snapshot;
|
|
809
|
+
if (table.get) {
|
|
810
|
+
snapshot = (await table.get(sourceCtx, recordId)) ?? undefined;
|
|
811
|
+
}
|
|
812
|
+
try {
|
|
813
|
+
const data = await table.actions.upsert(sourceCtx, recordId, fields);
|
|
814
|
+
this.wfCtx._recordStep(stepName, "action.upsert", start, {
|
|
815
|
+
input: { connector: this.connectorName, table: tableName, recordId, fields },
|
|
816
|
+
output: snapshot ? `updated ${recordId}` : `created ${recordId}`,
|
|
817
|
+
});
|
|
818
|
+
const result = { connector: this.connectorName, table: tableName, operation: "upsert", recordId, data, snapshot, operationId };
|
|
819
|
+
await this.wfCtx._logAction(result, fields);
|
|
820
|
+
return result;
|
|
821
|
+
}
|
|
822
|
+
catch (e) {
|
|
823
|
+
this.wfCtx._recordStep(stepName, "action.upsert", start, {
|
|
824
|
+
input: { connector: this.connectorName, table: tableName, recordId },
|
|
825
|
+
error: e.message,
|
|
826
|
+
});
|
|
827
|
+
throw e;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
const registry = new Map();
|
|
832
|
+
export function workflow(name, handler, options) {
|
|
833
|
+
const def = { name, handler, options };
|
|
834
|
+
registry.set(name, def);
|
|
835
|
+
return def;
|
|
836
|
+
}
|
|
837
|
+
export function getWorkflow(name) {
|
|
838
|
+
return registry.get(name);
|
|
839
|
+
}
|
|
840
|
+
export function allWorkflows() {
|
|
841
|
+
return [...registry.values()];
|
|
842
|
+
}
|
|
843
|
+
export function findTriggeredWorkflows(sourceName, tableName, event, isInitialSync) {
|
|
844
|
+
return allWorkflows().filter((def) => {
|
|
845
|
+
const t = def.options?.trigger;
|
|
846
|
+
if (!t || t.source !== sourceName)
|
|
847
|
+
return false;
|
|
848
|
+
if (t.table && t.table !== tableName)
|
|
849
|
+
return false;
|
|
850
|
+
if (isInitialSync && !t.includeInitialSync)
|
|
851
|
+
return false;
|
|
852
|
+
const on = t.on ?? "change";
|
|
853
|
+
return on === "change" || on === event;
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
async function ensureOpsSchema(ctx) {
|
|
857
|
+
await ctx.exec(OPS_DB, `CREATE TABLE IF NOT EXISTS workflow_runs (
|
|
858
|
+
id TEXT PRIMARY KEY,
|
|
859
|
+
workflow TEXT NOT NULL,
|
|
860
|
+
status TEXT NOT NULL,
|
|
861
|
+
started_at TEXT NOT NULL,
|
|
862
|
+
completed_at TEXT,
|
|
863
|
+
duration_ms INTEGER,
|
|
864
|
+
params TEXT,
|
|
865
|
+
result TEXT,
|
|
866
|
+
error TEXT
|
|
867
|
+
)`);
|
|
868
|
+
await ctx.exec(OPS_DB, `CREATE TABLE IF NOT EXISTS workflow_steps (
|
|
869
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
870
|
+
run_id TEXT NOT NULL REFERENCES workflow_runs(id),
|
|
871
|
+
step_name TEXT NOT NULL,
|
|
872
|
+
step_type TEXT NOT NULL,
|
|
873
|
+
started_at TEXT NOT NULL,
|
|
874
|
+
completed_at TEXT,
|
|
875
|
+
duration_ms INTEGER,
|
|
876
|
+
input TEXT,
|
|
877
|
+
output TEXT,
|
|
878
|
+
error TEXT,
|
|
879
|
+
tokens_used INTEGER,
|
|
880
|
+
retries INTEGER DEFAULT 0
|
|
881
|
+
)`);
|
|
882
|
+
await ctx.exec(OPS_DB, `CREATE TABLE IF NOT EXISTS actions (
|
|
883
|
+
id TEXT PRIMARY KEY,
|
|
884
|
+
workflow_run_id TEXT NOT NULL,
|
|
885
|
+
step_id TEXT,
|
|
886
|
+
connector TEXT NOT NULL,
|
|
887
|
+
table_name TEXT NOT NULL,
|
|
888
|
+
operation TEXT NOT NULL,
|
|
889
|
+
record_id TEXT,
|
|
890
|
+
before_snapshot TEXT,
|
|
891
|
+
after_payload TEXT,
|
|
892
|
+
after_confirmed TEXT,
|
|
893
|
+
rolled_back INTEGER DEFAULT 0,
|
|
894
|
+
rolled_back_at TEXT,
|
|
895
|
+
rollback_action_id TEXT,
|
|
896
|
+
created_at TEXT NOT NULL
|
|
897
|
+
)`);
|
|
898
|
+
await ctx.exec(OPS_DB, `CREATE INDEX IF NOT EXISTS idx_actions_workflow ON actions(workflow_run_id)`);
|
|
899
|
+
await ctx.exec(OPS_DB, `CREATE INDEX IF NOT EXISTS idx_actions_connector ON actions(connector, table_name)`);
|
|
900
|
+
await ctx.exec(OPS_DB, `CREATE INDEX IF NOT EXISTS idx_actions_operation ON actions(operation)`);
|
|
901
|
+
}
|
|
902
|
+
async function persistRun(ctx, result) {
|
|
903
|
+
try {
|
|
904
|
+
await ensureOpsSchema(ctx);
|
|
905
|
+
await ctx.exec(OPS_DB, `INSERT INTO workflow_runs (id, workflow, status, started_at, completed_at, duration_ms, result, error)
|
|
906
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [result.runId, result.workflow, result.status, result.startedAt, result.completedAt,
|
|
907
|
+
result.durationMs, result.result != null ? JSON.stringify(result.result) : null,
|
|
908
|
+
result.error ?? null]);
|
|
909
|
+
for (const step of result.steps) {
|
|
910
|
+
await ctx.exec(OPS_DB, `INSERT INTO workflow_steps (run_id, step_name, step_type, started_at, completed_at, duration_ms, input, output, error, tokens_used)
|
|
911
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [result.runId, step.name, step.type,
|
|
912
|
+
new Date(step.startedAt).toISOString(), step.completedAt ? new Date(step.completedAt).toISOString() : null,
|
|
913
|
+
step.durationMs ?? null, step.input ?? null, step.output ?? null, step.error ?? null, step.tokensUsed ?? null]);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
catch (e) {
|
|
917
|
+
console.error(`[ops] Failed to persist workflow run: ${e.message}`);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
export async function runWorkflow(name, env, params) {
|
|
921
|
+
const def = getWorkflow(name);
|
|
922
|
+
if (!def)
|
|
923
|
+
throw new Error(`Workflow "${name}" not found`);
|
|
924
|
+
const runId = `${name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
925
|
+
const wfCtx = new WorkflowContext(env, def.options);
|
|
926
|
+
wfCtx.changesetId = runId;
|
|
927
|
+
wfCtx.changesetSource = `workflow:${name}`;
|
|
928
|
+
wfCtx.instanceId = `local-${runId}`;
|
|
929
|
+
if (params)
|
|
930
|
+
wfCtx.params = params;
|
|
931
|
+
const opsCtx = new WorkspaceContext(env);
|
|
932
|
+
const startedAt = new Date();
|
|
933
|
+
let result;
|
|
934
|
+
try {
|
|
935
|
+
const value = await def.handler(wfCtx);
|
|
936
|
+
const completedAt = new Date();
|
|
937
|
+
result = {
|
|
938
|
+
workflow: name,
|
|
939
|
+
runId,
|
|
940
|
+
status: "complete",
|
|
941
|
+
startedAt: startedAt.toISOString(),
|
|
942
|
+
completedAt: completedAt.toISOString(),
|
|
943
|
+
durationMs: completedAt.getTime() - startedAt.getTime(),
|
|
944
|
+
stepCount: wfCtx.steps.length,
|
|
945
|
+
steps: wfCtx.steps,
|
|
946
|
+
result: value,
|
|
947
|
+
webhookResponse: wfCtx._webhookResponse,
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
catch (e) {
|
|
951
|
+
const completedAt = new Date();
|
|
952
|
+
result = {
|
|
953
|
+
workflow: name,
|
|
954
|
+
runId,
|
|
955
|
+
status: "errored",
|
|
956
|
+
startedAt: startedAt.toISOString(),
|
|
957
|
+
completedAt: completedAt.toISOString(),
|
|
958
|
+
durationMs: completedAt.getTime() - startedAt.getTime(),
|
|
959
|
+
stepCount: wfCtx.steps.length,
|
|
960
|
+
steps: wfCtx.steps,
|
|
961
|
+
error: e.message,
|
|
962
|
+
webhookResponse: wfCtx._webhookResponse,
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
await persistRun(opsCtx, result);
|
|
966
|
+
return result;
|
|
967
|
+
}
|
|
968
|
+
export async function queryLogs(env, workflowName, limit = 10) {
|
|
969
|
+
const ctx = new WorkspaceContext(env);
|
|
970
|
+
await ensureOpsSchema(ctx);
|
|
971
|
+
if (workflowName) {
|
|
972
|
+
const runs = await ctx.query(OPS_DB, `SELECT id, workflow, status, started_at, completed_at, duration_ms, error
|
|
973
|
+
FROM workflow_runs WHERE workflow = ? ORDER BY started_at DESC LIMIT ?`, [workflowName, limit]);
|
|
974
|
+
const result = [];
|
|
975
|
+
for (const run of runs) {
|
|
976
|
+
const steps = await ctx.query(OPS_DB, `SELECT step_name, step_type, duration_ms, input, output, error, tokens_used
|
|
977
|
+
FROM workflow_steps WHERE run_id = ? ORDER BY id`, [run.id]);
|
|
978
|
+
result.push({ ...run, steps });
|
|
979
|
+
}
|
|
980
|
+
return result;
|
|
981
|
+
}
|
|
982
|
+
return ctx.query(OPS_DB, `SELECT id, workflow, status, started_at, completed_at, duration_ms, error
|
|
983
|
+
FROM workflow_runs ORDER BY started_at DESC LIMIT ?`, [limit]);
|
|
984
|
+
}
|
|
985
|
+
export async function queryActions(env, filters, limit = 50) {
|
|
986
|
+
const ctx = new WorkspaceContext(env);
|
|
987
|
+
await ensureOpsSchema(ctx);
|
|
988
|
+
const conditions = [];
|
|
989
|
+
const params = [];
|
|
990
|
+
if (filters?.workflowRunId) {
|
|
991
|
+
conditions.push("workflow_run_id = ?");
|
|
992
|
+
params.push(filters.workflowRunId);
|
|
993
|
+
}
|
|
994
|
+
if (filters?.connector) {
|
|
995
|
+
conditions.push("connector = ?");
|
|
996
|
+
params.push(filters.connector);
|
|
997
|
+
}
|
|
998
|
+
if (filters?.operation) {
|
|
999
|
+
conditions.push("operation = ?");
|
|
1000
|
+
params.push(filters.operation);
|
|
1001
|
+
}
|
|
1002
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1003
|
+
params.push(limit);
|
|
1004
|
+
return ctx.query(OPS_DB, `SELECT id, workflow_run_id, connector, table_name, operation, record_id,
|
|
1005
|
+
before_snapshot, after_payload, after_confirmed, rolled_back,
|
|
1006
|
+
rolled_back_at, rollback_action_id, created_at
|
|
1007
|
+
FROM actions ${where} ORDER BY created_at DESC LIMIT ?`, params);
|
|
1008
|
+
}
|