@engenty/wizards-runtime 0.7.10 → 0.8.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-FIGURES +37 -0
- package/README.md +8 -2
- package/TRADEMARKS.md +47 -0
- package/apps/runtime/dist/authoring/guide.js +14 -2
- package/apps/runtime/dist/cli/exec.js +4 -1
- package/apps/runtime/dist/connectors/builtin.js +4 -2
- package/apps/runtime/dist/connectors/index.js +35 -4
- package/apps/runtime/dist/connectors/smtp.js +221 -0
- package/apps/runtime/dist/db/migrations/tenant/0018_share_job.sql +18 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/0018_snapshot.json +3460 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
- package/apps/runtime/dist/db/schema.js +19 -0
- package/apps/runtime/dist/docs/dev/index.md +25 -13
- package/apps/runtime/dist/docs/dev/meta.json +8 -2
- package/apps/runtime/dist/docs/dev/wizard-format/agent-steps.md +112 -0
- package/apps/runtime/dist/docs/dev/wizard-format/branches.md +63 -0
- package/apps/runtime/dist/docs/dev/wizard-format/film-steps.md +47 -0
- package/apps/runtime/dist/docs/dev/wizard-format/generate-steps.md +57 -0
- package/apps/runtime/dist/docs/dev/wizard-format/index.md +188 -0
- package/apps/runtime/dist/docs/dev/wizard-format/lists-and-connections.md +65 -0
- package/apps/runtime/dist/docs/dev/wizard-format/meta.json +19 -0
- package/apps/runtime/dist/docs/dev/wizard-format/pages-and-fields.md +102 -0
- package/apps/runtime/dist/docs/dev/wizard-format/review-and-result.md +64 -0
- package/apps/runtime/dist/docs/dev/wizard-format/templates.md +57 -0
- package/apps/runtime/dist/docs/dev/wizard-format/validate.md +63 -0
- package/apps/runtime/dist/docs/dev/wizard-format/views-and-widgets.md +103 -0
- package/apps/runtime/dist/docs/dev/wizard-format/wizard-file.md +147 -0
- package/apps/runtime/dist/docs/user/run.md +21 -4
- package/apps/runtime/dist/docs/user/steps.md +26 -1
- package/apps/runtime/dist/docs/user/test-and-share.md +3 -2
- package/apps/runtime/dist/engine/requirements.js +22 -7
- package/apps/runtime/dist/engine/runner.js +17 -8
- package/apps/runtime/dist/env.js +5 -0
- package/apps/runtime/dist/harness/antigravity.js +2 -10
- package/apps/runtime/dist/harness/claude.js +2 -10
- package/apps/runtime/dist/harness/codex.js +5 -11
- package/apps/runtime/dist/harness/cursor.js +2 -10
- package/apps/runtime/dist/harness/env.js +23 -0
- package/apps/runtime/dist/harness/index.js +6 -3
- package/apps/runtime/dist/routes/runs.js +106 -6
- package/apps/runtime/dist/routes/studio.js +12 -0
- package/apps/runtime/dist/services/engenty-shapes.js +3 -2
- package/apps/runtime/dist/sharing/agent.js +65 -0
- package/apps/runtime/dist/sharing/files.js +121 -0
- package/apps/runtime/dist/sharing/index.js +298 -0
- package/apps/runtime/dist/sharing/jobs.js +186 -0
- package/apps/runtime/dist/sharing/state.js +116 -0
- package/apps/runtime/dist/sharing/targets/browser.js +131 -0
- package/apps/runtime/dist/sharing/targets/connector.js +63 -0
- package/apps/runtime/dist/sharing/targets/email.js +98 -0
- package/apps/runtime/dist/sharing/targets/slack.js +54 -0
- package/apps/runtime/dist/sharing/targets/storage.js +164 -0
- package/apps/runtime/dist/sharing/types.js +13 -0
- package/apps/runtime/dist/texts.js +108 -0
- package/apps/runtime/dist/tools/browser.js +48 -0
- package/apps/runtime/dist/tools/connector.js +3 -2
- package/apps/runtime/dist/tools/store.js +18 -1
- package/apps/web/dist/assets/AboutDialog-CiiSwL4t.js +1 -0
- package/apps/web/dist/assets/{ChangelogDialog-B0lS0BdL.js → ChangelogDialog-BEyz7LNE.js} +1 -1
- package/apps/web/dist/assets/ChatView-9RkWts5F.js +10 -0
- package/apps/web/dist/assets/EditorPage-CCsKKcYo.js +4 -0
- package/apps/web/dist/assets/{FlowPreview-oX_Emm7u.js → FlowPreview-CLf8WHiG.js} +1 -1
- package/apps/web/dist/assets/{Harness-Dv9ln5xB.js → Harness-jc3jpWvI.js} +2 -2
- package/apps/web/dist/assets/NewWizardChannels-BPGZ4pfP.js +1 -0
- package/apps/web/dist/assets/NewWizardPage-tidycL7Y.js +7 -0
- package/apps/web/dist/assets/OnboardingPage-DtVuaIuv.js +1 -0
- package/apps/web/dist/assets/ProjectPage-BsWgCP5z.js +1 -0
- package/apps/web/dist/assets/PublicRunner-CkA_etKX.js +1 -0
- package/apps/web/dist/assets/{ReadOnly-S9vGVQHr.js → ReadOnly-BVwbqvpi.js} +1 -1
- package/apps/web/dist/assets/RunDrawer-CkFpnWCC.js +4 -0
- package/apps/web/dist/assets/{RunnerView-BW1h4x63.js → RunnerView-CxTFM-lx.js} +1 -1
- package/apps/web/dist/assets/SettingsPage-DM1wNAE7.js +1 -0
- package/apps/web/dist/assets/SetupPage-BKnQqiab.js +1 -0
- package/apps/web/dist/assets/{SharePage-ktqm-jWI.js → SharePage-CTTSudtp.js} +1 -1
- package/apps/web/dist/assets/changelog-DBEwyB1f.js +1 -0
- package/apps/web/dist/assets/engenty-Cko4qGFV.js +1 -0
- package/apps/web/dist/assets/{esm-C-ZWeuhU.js → esm-DtNAg8uF.js} +1 -1
- package/apps/web/dist/assets/fluffy-engenty-Db7agOjV.js +520 -0
- package/apps/web/dist/assets/index-V1M59Yez.css +1 -0
- package/apps/web/dist/assets/index-xIz8znhq.js +14 -0
- package/apps/web/dist/assets/{oss-credits-Bc05Qv5H.js → oss-credits-BWE03Xax.js} +1 -1
- package/apps/web/dist/assets/{phone-BjmhIKuT.js → phone-5SYD9OrJ.js} +1 -1
- package/apps/web/dist/assets/{settings-menu-Dpkp3f56.js → settings-menu-Bn5-7Lqk.js} +1 -1
- package/apps/web/dist/assets/ui-s39eGOtW.js +137 -0
- package/apps/web/dist/assets/{upload-JX9BcMTq.js → upload-Bevcvx5V.js} +1 -1
- package/apps/web/dist/assets/{wrench-BSxnPs2_.js → wrench-ZD5SCsz9.js} +1 -1
- package/apps/web/dist/favicon.svg +2 -1
- package/apps/web/dist/icons/apple-touch-icon.png +0 -0
- package/apps/web/dist/icons/icon-192.png +0 -0
- package/apps/web/dist/icons/icon-512.png +0 -0
- package/apps/web/dist/icons/icon-maskable-512.png +0 -0
- package/apps/web/dist/index.html +4 -4
- package/apps/web/dist/mcp/flow.html +65 -120
- package/node_modules/@engenty-wizards/shared/dist/definition.d.ts +22 -0
- package/node_modules/@engenty-wizards/shared/dist/definition.js +26 -0
- package/node_modules/@engenty-wizards/shared/dist/run.d.ts +6 -0
- package/node_modules/@engenty-wizards/shared/dist/run.js +2 -0
- package/node_modules/@engenty-wizards/shared/dist/sharing.d.ts +135 -0
- package/node_modules/@engenty-wizards/shared/dist/sharing.js +11 -0
- package/node_modules/@engenty-wizards/shared/dist/store.d.ts +8 -0
- package/package.json +6 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/hooks/engenty.ts +3 -2
- package/apps/runtime/dist/docs/dev/wizard-format.md +0 -125
- package/apps/web/dist/assets/AboutDialog-B2LuZ_mC.js +0 -1
- package/apps/web/dist/assets/ChatView-BcPDcU2d.js +0 -10
- package/apps/web/dist/assets/EditorPage-erduYKNM.js +0 -4
- package/apps/web/dist/assets/NewWizardChannels-JveYyh-U.js +0 -1
- package/apps/web/dist/assets/NewWizardPage-Dfk-WOhX.js +0 -7
- package/apps/web/dist/assets/OnboardingPage-CT9dL4ke.js +0 -1
- package/apps/web/dist/assets/ProjectPage-5_Wsk9Hv.js +0 -1
- package/apps/web/dist/assets/PublicRunner-Cw1WzTze.js +0 -1
- package/apps/web/dist/assets/RunDrawer-O4rEFCL8.js +0 -4
- package/apps/web/dist/assets/SettingsPage-DmhN7OTy.js +0 -1
- package/apps/web/dist/assets/SetupPage-BlWd7Uln.js +0 -1
- package/apps/web/dist/assets/changelog-FJJS0Tpt.js +0 -1
- package/apps/web/dist/assets/engenty-WpMi3xwq.js +0 -1
- package/apps/web/dist/assets/fluffy-engenty-DPNHytrz.js +0 -575
- package/apps/web/dist/assets/index-C4aHMcg9.css +0 -1
- package/apps/web/dist/assets/index-xH9uN8Nh.js +0 -14
- package/apps/web/dist/assets/ui-DMmT-cSO.js +0 -137
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { FORMATS, LIST_FORMATS, listRef, } from "@engenty-wizards/shared/definition";
|
|
2
|
+
import { listDownload, renderDownload } from "../render/downloads.js";
|
|
3
|
+
import { listRows, scopeOf } from "../store/index.js";
|
|
4
|
+
import { line, ShareError } from "./types.js";
|
|
5
|
+
/** The result step a finished run stands on. */
|
|
6
|
+
export function resultStepOf(run) {
|
|
7
|
+
const steps = run.definition.steps;
|
|
8
|
+
const step = steps.find((s) => s.id === run.cursor && s.type === "result") ??
|
|
9
|
+
steps.findLast((s) => s.type === "result");
|
|
10
|
+
return step?.type === "result" ? step : null;
|
|
11
|
+
}
|
|
12
|
+
/** One output of a run as a file: a step's output or a stored list, in one format. */
|
|
13
|
+
export async function resultFile(source, from, format) {
|
|
14
|
+
const { def, state, store } = source;
|
|
15
|
+
const deliverable = def.steps
|
|
16
|
+
.flatMap((s) => (s.type === "result" ? s.deliverables : []))
|
|
17
|
+
.find((d) => d.from === from);
|
|
18
|
+
const list = listRef(from);
|
|
19
|
+
if (list) {
|
|
20
|
+
const listDef = def.lists?.find((l) => l.id === list);
|
|
21
|
+
if (!listDef || !LIST_FORMATS.includes(format)) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const label = deliverable?.label ?? listDef.title;
|
|
25
|
+
const download = await listDownload(listDef, await listRows(store, listDef), format, `${def.title} ${label}`);
|
|
26
|
+
return download ? { download, label } : null;
|
|
27
|
+
}
|
|
28
|
+
const step = def.steps.find((s) => s.id === from);
|
|
29
|
+
const output = step ? state.outputs[step.id] : undefined;
|
|
30
|
+
if (!step || !output) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const label = deliverable?.label ?? step.title;
|
|
34
|
+
const download = await renderDownload(step, output, format, `${def.title} ${label}`);
|
|
35
|
+
return download ? { download, label } : null;
|
|
36
|
+
}
|
|
37
|
+
const RESULT_REF = /^result:(.+)\.([a-z0-9]+)$/;
|
|
38
|
+
/** `result:<step>.<format>` — how an agent names an output of the run in a tool call. */
|
|
39
|
+
export function parseResultRef(ref) {
|
|
40
|
+
const m = ref.match(RESULT_REF);
|
|
41
|
+
const format = m ? FORMATS.find((f) => f === m[2]) : undefined;
|
|
42
|
+
return m && format ? { from: m[1], format: format } : null;
|
|
43
|
+
}
|
|
44
|
+
function bytes(data) {
|
|
45
|
+
return typeof data === "string" ? new TextEncoder().encode(data) : data;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The files the person picked, rendered. Only what the result offers as a download can be sent:
|
|
49
|
+
* a deliverable, in one of its formats.
|
|
50
|
+
*/
|
|
51
|
+
export async function shareFiles(run, items) {
|
|
52
|
+
const result = resultStepOf(run);
|
|
53
|
+
const source = { def: run.definition, state: run.state, store: scopeOf(run) };
|
|
54
|
+
const files = [];
|
|
55
|
+
const names = new Set();
|
|
56
|
+
for (const item of items) {
|
|
57
|
+
const offered = result?.deliverables.find((d) => d.from === item.from && d.formats.includes(item.format));
|
|
58
|
+
if (!offered) {
|
|
59
|
+
throw new ShareError(line("shareNotInResult", { name: item.from, format: item.format }));
|
|
60
|
+
}
|
|
61
|
+
const found = await resultFile(source, item.from, item.format).catch(() => null);
|
|
62
|
+
if (!found) {
|
|
63
|
+
throw new ShareError(line("shareCannotRender", {
|
|
64
|
+
name: offered.label ?? item.from,
|
|
65
|
+
format: item.format.toUpperCase(),
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
let name = found.download.filename;
|
|
69
|
+
for (let n = 2; names.has(name); n++) {
|
|
70
|
+
name = found.download.filename.replace(/(\.[^.]+)?$/, (ext) => `-${n}${ext}`);
|
|
71
|
+
}
|
|
72
|
+
names.add(name);
|
|
73
|
+
files.push({
|
|
74
|
+
name,
|
|
75
|
+
mime: found.download.mime,
|
|
76
|
+
data: bytes(found.download.data),
|
|
77
|
+
label: found.label,
|
|
78
|
+
ref: `result:${item.from}.${item.format}`,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return files;
|
|
82
|
+
}
|
|
83
|
+
/** The first written deliverable of the result, as text. */
|
|
84
|
+
export function resultText(run) {
|
|
85
|
+
for (const d of resultStepOf(run)?.deliverables ?? []) {
|
|
86
|
+
const step = run.definition.steps.find((s) => s.id === d.from);
|
|
87
|
+
const text = run.state.outputs[d.from]?.text?.trim();
|
|
88
|
+
if (step?.type === "agent" && step.output.format !== "json" && text) {
|
|
89
|
+
return text;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
/** What the result holds, for an agent that is to publish it: every deliverable, texts in full. */
|
|
95
|
+
export function resultBrief(run) {
|
|
96
|
+
const lines = [];
|
|
97
|
+
for (const d of resultStepOf(run)?.deliverables ?? []) {
|
|
98
|
+
const step = run.definition.steps.find((s) => s.id === d.from);
|
|
99
|
+
const output = run.state.outputs[d.from];
|
|
100
|
+
if (!step || !output) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const title = d.label ?? step.title;
|
|
104
|
+
if (step.type === "agent" && step.output.format === "json") {
|
|
105
|
+
lines.push(`## ${title} (data)\n${JSON.stringify(output.json, null, 2).slice(0, 6000)}`);
|
|
106
|
+
}
|
|
107
|
+
else if (step.type === "agent") {
|
|
108
|
+
lines.push(`## ${title} (text)\n${(output.text ?? "").slice(0, 12_000)}`);
|
|
109
|
+
}
|
|
110
|
+
else if (step.type === "generate") {
|
|
111
|
+
lines.push(`## ${title} (${step.asset})`);
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
lines.push(`## ${title}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return lines.join("\n\n") || "(nothing)";
|
|
118
|
+
}
|
|
119
|
+
export function megabytes(n) {
|
|
120
|
+
return `${(n / 1_000_000).toFixed(n < 10_000_000 ? 1 : 0)} MB`;
|
|
121
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { and, desc, eq } from "drizzle-orm";
|
|
2
|
+
import { listConnectors, resolveConnector } from "../connectors/external.js";
|
|
3
|
+
import { connectionViews, connectorOptions, registerConnectionSource, } from "../connectors/index.js";
|
|
4
|
+
import { db, schema } from "../db/client.js";
|
|
5
|
+
import { renderTemplate } from "../engine/template.js";
|
|
6
|
+
import { scopeOf } from "../store/index.js";
|
|
7
|
+
import { currentTenant } from "../tenants/tenant.js";
|
|
8
|
+
import { resultStepOf, resultText } from "./files.js";
|
|
9
|
+
import { shareJobs } from "./state.js";
|
|
10
|
+
import { browserTarget } from "./targets/browser.js";
|
|
11
|
+
import { canPublish, connectorTarget } from "./targets/connector.js";
|
|
12
|
+
import { emailTarget } from "./targets/email.js";
|
|
13
|
+
import { slackTarget } from "./targets/slack.js";
|
|
14
|
+
import { storageConnectors, storageTarget } from "./targets/storage.js";
|
|
15
|
+
import { sayIn } from "./types.js";
|
|
16
|
+
/**
|
|
17
|
+
* The share targets of this runtime. The fixed ones are listed here; every connector that
|
|
18
|
+
* stores files adds `storage:<connector>`, every connector that can write `connector:<connector>`.
|
|
19
|
+
* To add a target, write it in `./targets` and name it here.
|
|
20
|
+
*/
|
|
21
|
+
const FIXED = [emailTarget, slackTarget, browserTarget];
|
|
22
|
+
/** A share target by its id; null when this project has none of that name. */
|
|
23
|
+
export async function shareTarget(projectId, id) {
|
|
24
|
+
const fixed = FIXED.find((t) => t.id === id);
|
|
25
|
+
if (fixed) {
|
|
26
|
+
return fixed;
|
|
27
|
+
}
|
|
28
|
+
const cut = id.indexOf(":");
|
|
29
|
+
const [kind, connectorId] = [id.slice(0, cut), id.slice(cut + 1)];
|
|
30
|
+
if (kind === "storage") {
|
|
31
|
+
const connector = storageConnectors().find((c) => c.id === connectorId);
|
|
32
|
+
return connector ? storageTarget(connector) : null;
|
|
33
|
+
}
|
|
34
|
+
if (kind === "connector") {
|
|
35
|
+
const found = await resolveConnector(projectId, connectorId);
|
|
36
|
+
return found && canPublish(found.connector)
|
|
37
|
+
? connectorTarget(found.connector, found.imported)
|
|
38
|
+
: null;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
/** Every target of a project, in the order a result shows them. */
|
|
43
|
+
async function projectTargets(projectId) {
|
|
44
|
+
const imported = (await listConnectors(projectId)).filter((c) => c.sourceKind !== "builtin");
|
|
45
|
+
const through = await Promise.all(imported.map((c) => shareTarget(projectId, `connector:${c.id}`)));
|
|
46
|
+
return [
|
|
47
|
+
emailTarget,
|
|
48
|
+
...storageConnectors().flatMap((c) => storageTarget(c) ?? []),
|
|
49
|
+
slackTarget,
|
|
50
|
+
...through.filter((t) => Boolean(t)),
|
|
51
|
+
browserTarget,
|
|
52
|
+
];
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Every target a wizard of the project can name on its result, for the editor and for whoever
|
|
56
|
+
* authors a wizard: the default ones, and one for each built-in connector that can write.
|
|
57
|
+
*/
|
|
58
|
+
export async function projectShareTargets(projectId, lang) {
|
|
59
|
+
const targets = await projectTargets(projectId);
|
|
60
|
+
const builtin = (await listConnectors(projectId)).filter((c) => c.sourceKind === "builtin");
|
|
61
|
+
for (const connector of builtin) {
|
|
62
|
+
const target = await shareTarget(projectId, `connector:${connector.id}`);
|
|
63
|
+
if (target) {
|
|
64
|
+
targets.push(target);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return Promise.all(targets.map(async (target) => ({
|
|
68
|
+
id: target.id,
|
|
69
|
+
name: sayIn(lang, target.name),
|
|
70
|
+
description: sayIn(lang, target.description),
|
|
71
|
+
icon: target.icon,
|
|
72
|
+
agent: Boolean(target.agent),
|
|
73
|
+
usable: await offerable(target, projectId),
|
|
74
|
+
byDefault: target.byDefault !== false,
|
|
75
|
+
fields: target.fields.map((f) => ({ id: f.id, label: sayIn(lang, f.label), kind: f.kind })),
|
|
76
|
+
})));
|
|
77
|
+
}
|
|
78
|
+
/** Whether a target can work here right now: what it drives is there, its account can be connected. */
|
|
79
|
+
async function offerable(target, projectId) {
|
|
80
|
+
if (target.available && !(await target.available())) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const connection = target.connection?.();
|
|
84
|
+
return !connection || (await connectorOptions(connection, projectId)).length > 0;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The targets a result names: its own list, else every target of the project a result offers
|
|
88
|
+
* by default. `missing` names listed targets this project does not have.
|
|
89
|
+
*/
|
|
90
|
+
async function namedTargets(run, projectId) {
|
|
91
|
+
const result = resultStepOf(run);
|
|
92
|
+
if (!result) {
|
|
93
|
+
return { entries: [], listed: true, missing: [] };
|
|
94
|
+
}
|
|
95
|
+
if (!result.share) {
|
|
96
|
+
const entries = (await projectTargets(projectId))
|
|
97
|
+
.filter((target) => target.byDefault !== false)
|
|
98
|
+
.map((target) => ({ key: target.id, target, values: {} }));
|
|
99
|
+
return { entries, listed: false, missing: [] };
|
|
100
|
+
}
|
|
101
|
+
const entries = [];
|
|
102
|
+
const missing = [];
|
|
103
|
+
for (const [i, option] of result.share.entries()) {
|
|
104
|
+
const target = await shareTarget(projectId, option.target);
|
|
105
|
+
if (target) {
|
|
106
|
+
entries.push({
|
|
107
|
+
key: `${i}:${option.target}`,
|
|
108
|
+
target,
|
|
109
|
+
label: option.label,
|
|
110
|
+
values: option.values ?? {},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
missing.push(option.label ?? option.target);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { entries, listed: true, missing };
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The targets a finished run offers. A wizard that lists them on its result decides; else every
|
|
121
|
+
* target that needs no model is offered, and the ones an agent drives only to the wizard's owner
|
|
122
|
+
* — they run on the wizard's credits.
|
|
123
|
+
*/
|
|
124
|
+
export async function offeredTargets(run, projectId, member) {
|
|
125
|
+
const { entries, listed } = await namedTargets(run, projectId);
|
|
126
|
+
const asked = entries.filter((e) => listed || !e.target.agent || member);
|
|
127
|
+
const usable = await Promise.all(asked.map((e) => offerable(e.target, projectId)));
|
|
128
|
+
return asked.filter((_, i) => usable[i]);
|
|
129
|
+
}
|
|
130
|
+
export function targetScope(run, projectId, lang) {
|
|
131
|
+
const result = resultStepOf(run);
|
|
132
|
+
return {
|
|
133
|
+
run,
|
|
134
|
+
projectId,
|
|
135
|
+
lang,
|
|
136
|
+
title: [result?.title, run.definition.title].filter(Boolean).join(" · "),
|
|
137
|
+
message: result?.message ?? null,
|
|
138
|
+
text: resultText(run),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
async function templateScope(run, projectId) {
|
|
142
|
+
const project = await db.query.project.findFirst({ where: eq(schema.project.id, projectId) });
|
|
143
|
+
return {
|
|
144
|
+
def: run.definition,
|
|
145
|
+
state: run.state,
|
|
146
|
+
brand: project?.brand ?? {},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
async function targetView(entry, scope, templates) {
|
|
150
|
+
const { target } = entry;
|
|
151
|
+
const { lang } = scope;
|
|
152
|
+
const connection = target.connection?.() ?? null;
|
|
153
|
+
const [account] = connection
|
|
154
|
+
? await connectionViews([connection], scopeOf(scope.run), scope.projectId, lang)
|
|
155
|
+
: [null];
|
|
156
|
+
const defaults = (await target.defaults?.(scope)) ?? {};
|
|
157
|
+
return {
|
|
158
|
+
key: entry.key,
|
|
159
|
+
target: target.id,
|
|
160
|
+
name: entry.label ?? sayIn(lang, target.name),
|
|
161
|
+
description: sayIn(lang, target.description),
|
|
162
|
+
icon: target.icon,
|
|
163
|
+
connection: account,
|
|
164
|
+
fields: target.fields.map((f) => ({
|
|
165
|
+
...f,
|
|
166
|
+
label: sayIn(lang, f.label),
|
|
167
|
+
placeholder: f.placeholder && sayIn(lang, f.placeholder),
|
|
168
|
+
help: f.help && sayIn(lang, f.help),
|
|
169
|
+
value: entry.values[f.id] !== undefined
|
|
170
|
+
? renderTemplate(entry.values[f.id], templates)
|
|
171
|
+
: (defaults[f.id] ?? ""),
|
|
172
|
+
})),
|
|
173
|
+
takes: target.takes,
|
|
174
|
+
agent: Boolean(target.agent),
|
|
175
|
+
presets: (target.presets ?? []).map((p) => ({
|
|
176
|
+
label: p.label,
|
|
177
|
+
values: Object.fromEntries(Object.entries(p.values).map(([id, v]) => [id, sayIn(lang, v)])),
|
|
178
|
+
})),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** What the share sheet shows: the targets of this result and what was sent before. */
|
|
182
|
+
export async function shareOverview(run, projectId, member, lang) {
|
|
183
|
+
const scope = targetScope(run, projectId, lang);
|
|
184
|
+
const templates = await templateScope(run, projectId);
|
|
185
|
+
const offered = await offeredTargets(run, projectId, member);
|
|
186
|
+
const jobs = await shareJobs(run.id);
|
|
187
|
+
return {
|
|
188
|
+
targets: await Promise.all(offered.map((entry) => targetView(entry, scope, templates))),
|
|
189
|
+
jobs,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const PLACEHOLDER = /\{\{\s*([^}]+?)\s*\}\}/g;
|
|
193
|
+
/** The placeholders of prefilled values that come out empty. */
|
|
194
|
+
function emptyPlaceholders(values, templates) {
|
|
195
|
+
const empty = new Set();
|
|
196
|
+
for (const value of Object.values(values)) {
|
|
197
|
+
for (const [whole, ref] of value.matchAll(PLACEHOLDER)) {
|
|
198
|
+
if (!renderTemplate(whole, templates).trim()) {
|
|
199
|
+
empty.add(ref.trim());
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return [...empty];
|
|
204
|
+
}
|
|
205
|
+
/** How much of a written deliverable the preview shows. */
|
|
206
|
+
const EXCERPT = 1500;
|
|
207
|
+
/** A run that never happened, for a wizard without a finished test run: the draft, no answers. */
|
|
208
|
+
function standIn(wizard, userId) {
|
|
209
|
+
const now = new Date();
|
|
210
|
+
return {
|
|
211
|
+
id: `preview-${wizard.id}`,
|
|
212
|
+
wizardId: wizard.id,
|
|
213
|
+
tenantId: wizard.tenantId,
|
|
214
|
+
definition: wizard.draft,
|
|
215
|
+
files: [],
|
|
216
|
+
version: null,
|
|
217
|
+
mode: "test",
|
|
218
|
+
runner: null,
|
|
219
|
+
lang: null,
|
|
220
|
+
visitorId: null,
|
|
221
|
+
userId,
|
|
222
|
+
ipHash: null,
|
|
223
|
+
origin: null,
|
|
224
|
+
client: null,
|
|
225
|
+
status: "done",
|
|
226
|
+
cursor: null,
|
|
227
|
+
state: { values: {}, outputs: {}, history: [], notes: {} },
|
|
228
|
+
error: null,
|
|
229
|
+
ask: null,
|
|
230
|
+
answer: null,
|
|
231
|
+
unattended: false,
|
|
232
|
+
deviceOffer: null,
|
|
233
|
+
costMicros: 0,
|
|
234
|
+
shareToken: null,
|
|
235
|
+
sharedAt: null,
|
|
236
|
+
expiresAt: null,
|
|
237
|
+
createdAt: now,
|
|
238
|
+
updatedAt: now,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* The draft's result page as a visitor (`member` false) or its owner gets it: its share targets,
|
|
243
|
+
* filled from the wizard's last finished test run, and what that run wrote. `resultId` picks the
|
|
244
|
+
* result step where the draft has several.
|
|
245
|
+
*/
|
|
246
|
+
export async function sharePreview(wizard, userId, resultId, member, lang) {
|
|
247
|
+
const last = await db.query.run.findFirst({
|
|
248
|
+
where: and(eq(schema.run.tenantId, currentTenant()), eq(schema.run.wizardId, wizard.id), eq(schema.run.mode, "test"), eq(schema.run.status, "done")),
|
|
249
|
+
orderBy: [desc(schema.run.createdAt)],
|
|
250
|
+
});
|
|
251
|
+
// The draft as it is now, on what the last test run answered and made.
|
|
252
|
+
const run = {
|
|
253
|
+
...(last ?? standIn(wizard, userId)),
|
|
254
|
+
definition: wizard.draft,
|
|
255
|
+
cursor: resultId,
|
|
256
|
+
};
|
|
257
|
+
const scope = targetScope(run, wizard.projectId, lang);
|
|
258
|
+
const templates = await templateScope(run, wizard.projectId);
|
|
259
|
+
const { entries, listed, missing } = await namedTargets(run, wizard.projectId);
|
|
260
|
+
const targets = [];
|
|
261
|
+
const left = missing.map((name) => ({ name, why: "setup" }));
|
|
262
|
+
for (const entry of entries) {
|
|
263
|
+
const name = entry.label ?? sayIn(lang, entry.target.name);
|
|
264
|
+
if (!listed && entry.target.agent && !member) {
|
|
265
|
+
left.push({ name, why: "agent" });
|
|
266
|
+
}
|
|
267
|
+
else if (!(await offerable(entry.target, wizard.projectId))) {
|
|
268
|
+
left.push({ name, why: "setup" });
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
targets.push({
|
|
272
|
+
...(await targetView(entry, scope, templates)),
|
|
273
|
+
empty: emptyPlaceholders(entry.values, templates),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const outputs = {};
|
|
278
|
+
for (const d of resultStepOf(run)?.deliverables ?? []) {
|
|
279
|
+
const text = run.state.outputs[d.from]?.text?.trim();
|
|
280
|
+
if (text) {
|
|
281
|
+
outputs[d.from] = text.slice(0, EXCERPT);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
run: last ? { id: last.id, at: last.updatedAt.toISOString() } : null,
|
|
286
|
+
targets,
|
|
287
|
+
left,
|
|
288
|
+
outputs,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
// The account a target sends with is a connection of its own, kept like the wizard's connections.
|
|
292
|
+
registerConnectionSource(async (_run, connectionId, projectId) => {
|
|
293
|
+
if (!connectionId.startsWith("share:")) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
const target = await shareTarget(projectId, connectionId.slice("share:".length));
|
|
297
|
+
return target?.connection?.() ?? null;
|
|
298
|
+
});
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { noteText } from "@engenty-wizards/shared/run";
|
|
2
|
+
import { connectionContext } from "../connectors/index.js";
|
|
3
|
+
import { askPerson } from "../engine/asks.js";
|
|
4
|
+
import { signalChanged } from "../engine/events.js";
|
|
5
|
+
import { releaseResources } from "../engine/resources.js";
|
|
6
|
+
import { loadProject, makeContext, projectIdOf } from "../engine/runner.js";
|
|
7
|
+
import { env } from "../env.js";
|
|
8
|
+
import { errorText } from "../model-errors.js";
|
|
9
|
+
import { ServiceError } from "../services/errors.js";
|
|
10
|
+
import { shareRun } from "../services/shares.js";
|
|
11
|
+
import { scopeOf } from "../store/index.js";
|
|
12
|
+
import { serverText } from "../texts.js";
|
|
13
|
+
import { SHARE_STEP } from "./agent.js";
|
|
14
|
+
import { shareFiles } from "./files.js";
|
|
15
|
+
import { offeredTargets, targetScope } from "./index.js";
|
|
16
|
+
import { addJobEvent, createJob, finishJob, jobCount, sentCount, setWorking, workingJob, } from "./state.js";
|
|
17
|
+
import { parseRecipients } from "./targets/email.js";
|
|
18
|
+
import { ShareError, sayIn } from "./types.js";
|
|
19
|
+
/** What the person filled in does not do: per field, what is wrong. `_files` and `_account` name the other steps. */
|
|
20
|
+
export class ShareInputError extends Error {
|
|
21
|
+
fields;
|
|
22
|
+
constructor(fields, said) {
|
|
23
|
+
super(Object.values(fields)[0] ?? said);
|
|
24
|
+
this.fields = fields;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Jobs one run of a person without an account may start. */
|
|
28
|
+
const VISITOR_JOBS = 20;
|
|
29
|
+
/** A link people elsewhere can open: not one that points at this machine. */
|
|
30
|
+
function publicOrigin() {
|
|
31
|
+
const host = new URL(env.appUrl).hostname;
|
|
32
|
+
return !(host === "localhost" ||
|
|
33
|
+
host.endsWith(".localhost") ||
|
|
34
|
+
host.endsWith(".local") ||
|
|
35
|
+
host === "127.0.0.1" ||
|
|
36
|
+
host === "[::1]");
|
|
37
|
+
}
|
|
38
|
+
function contextOf(run, projectId, entry, who, job) {
|
|
39
|
+
const connection = entry.target.connection?.() ?? null;
|
|
40
|
+
const signal = job?.signal ?? new AbortController().signal;
|
|
41
|
+
const emit = (message) => job ? addJobEvent(run.id, job.id, sayIn(who.lang, message)) : Promise.resolve();
|
|
42
|
+
const ask = (question) => !job || run.unattended
|
|
43
|
+
? Promise.resolve(null)
|
|
44
|
+
: askPerson(run.id, { ...question, stepId: SHARE_STEP }, signal);
|
|
45
|
+
return {
|
|
46
|
+
...targetScope(run, projectId, who.lang),
|
|
47
|
+
signal,
|
|
48
|
+
member: who.member,
|
|
49
|
+
sentBefore: job?.sentBefore ?? 0,
|
|
50
|
+
emit,
|
|
51
|
+
ask,
|
|
52
|
+
account: () => connection ? connectionContext(scopeOf(run), connection, projectId) : Promise.resolve(null),
|
|
53
|
+
link: async () => (publicOrigin() ? (await shareRun(run)).url : null),
|
|
54
|
+
step: async () => makeContext(run, await loadProject(run), structuredClone(run.state), signal, {
|
|
55
|
+
stepId: SHARE_STEP,
|
|
56
|
+
emit: (_type, message) => emit(typeof message === "string" ? message : noteText(message, who.lang)),
|
|
57
|
+
ask,
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
async function entryOf(run, projectId, key, member) {
|
|
62
|
+
const entry = (await offeredTargets(run, projectId, member)).find((e) => e.key === key);
|
|
63
|
+
if (!entry) {
|
|
64
|
+
throw new ServiceError("not_found", { server: "shareTargetUnknown" });
|
|
65
|
+
}
|
|
66
|
+
return entry;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Sends a finished result to one target. Answers as soon as the job stands; the work goes on in
|
|
70
|
+
* the background and shows in the run's view. One job per run at a time.
|
|
71
|
+
*/
|
|
72
|
+
export async function startShare(run, request, who) {
|
|
73
|
+
if (run.status !== "done") {
|
|
74
|
+
throw new ServiceError("refused", { server: "shareOnlyDone" });
|
|
75
|
+
}
|
|
76
|
+
if (workingJob(run.id)) {
|
|
77
|
+
throw new ServiceError("refused", { server: "shareBusy" });
|
|
78
|
+
}
|
|
79
|
+
// A run of someone without an account sends a limited number of times: it is no mail relay.
|
|
80
|
+
if (!who.member && (await jobCount(run.id)) >= VISITOR_JOBS) {
|
|
81
|
+
throw new ServiceError("refused", { server: "shareTooOften" });
|
|
82
|
+
}
|
|
83
|
+
const lang = request.lang ?? "en";
|
|
84
|
+
const projectId = await projectIdOf(run);
|
|
85
|
+
const entry = await entryOf(run, projectId, request.target, who.member);
|
|
86
|
+
const { target } = entry;
|
|
87
|
+
const values = {};
|
|
88
|
+
const errors = {};
|
|
89
|
+
for (const field of target.fields) {
|
|
90
|
+
const value = String(request.values[field.id] ?? "")
|
|
91
|
+
.trim()
|
|
92
|
+
.slice(0, field.kind === "textarea" ? 20_000 : 2000);
|
|
93
|
+
values[field.id] = value;
|
|
94
|
+
if (!value) {
|
|
95
|
+
if (field.required) {
|
|
96
|
+
errors[field.id] = serverText(lang, "shareRequired");
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (field.kind === "url" && !/^https?:\/\/[^\s/]+\.[^\s]+$/i.test(value)) {
|
|
101
|
+
errors[field.id] = serverText(lang, "shareNeedsHttps");
|
|
102
|
+
}
|
|
103
|
+
if (field.kind === "emails") {
|
|
104
|
+
try {
|
|
105
|
+
parseRecipients(value);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
errors[field.id] = errorText(err, lang);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const items = target.takes === "files" ? request.items.slice(0, 20) : [];
|
|
113
|
+
if (target.needsFiles && !items.length) {
|
|
114
|
+
errors._files = serverText(lang, "sharePickFile");
|
|
115
|
+
}
|
|
116
|
+
const connection = target.connection?.() ?? null;
|
|
117
|
+
if (connection && !(await connectionContext(scopeOf(run), connection, projectId))) {
|
|
118
|
+
errors._account = serverText(lang, "shareConnectAccount");
|
|
119
|
+
}
|
|
120
|
+
if (Object.keys(errors).length) {
|
|
121
|
+
throw new ShareInputError(errors, serverText(lang, "shareCheckInput"));
|
|
122
|
+
}
|
|
123
|
+
const job = await createJob({
|
|
124
|
+
runId: run.id,
|
|
125
|
+
target: target.id,
|
|
126
|
+
name: entry.label ?? sayIn(lang, target.name),
|
|
127
|
+
icon: target.icon,
|
|
128
|
+
request: { target: request.target, items, values, lang },
|
|
129
|
+
});
|
|
130
|
+
const abort = new AbortController();
|
|
131
|
+
setWorking(run.id, { jobId: job.id, abort });
|
|
132
|
+
const ctx = contextOf(run, projectId, entry, { member: who.member, lang }, { id: job.id, signal: abort.signal, sentBefore: await sentCount(run.id, target.id) });
|
|
133
|
+
void (async () => {
|
|
134
|
+
try {
|
|
135
|
+
if (items.length) {
|
|
136
|
+
await ctx.emit({ server: "sharePreparing" });
|
|
137
|
+
}
|
|
138
|
+
const files = await shareFiles(run, items);
|
|
139
|
+
const outcome = await target.send({ values, files }, ctx);
|
|
140
|
+
await finishJob(run.id, job.id, abort.signal.aborted
|
|
141
|
+
? { status: "cancelled" }
|
|
142
|
+
: {
|
|
143
|
+
status: "done",
|
|
144
|
+
result: { summary: sayIn(lang, outcome.summary), url: outcome.url ?? null },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
if (!abort.signal.aborted && !(err instanceof ShareError)) {
|
|
149
|
+
console.error(`[share ${run.id} → ${target.id}]`, err);
|
|
150
|
+
}
|
|
151
|
+
await finishJob(run.id, job.id, abort.signal.aborted
|
|
152
|
+
? { status: "cancelled" }
|
|
153
|
+
: {
|
|
154
|
+
status: "failed",
|
|
155
|
+
error: err instanceof ShareError
|
|
156
|
+
? errorText(err, lang)
|
|
157
|
+
: `${serverText(lang, "shareFailed")}: ${String(err?.message ?? err).slice(0, 240)}`,
|
|
158
|
+
}).catch(() => undefined);
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
setWorking(run.id, null);
|
|
162
|
+
await releaseResources(run.id).catch(() => undefined);
|
|
163
|
+
signalChanged(run.id);
|
|
164
|
+
}
|
|
165
|
+
})();
|
|
166
|
+
return job;
|
|
167
|
+
}
|
|
168
|
+
/** Stops the job a run is working on; what it already sent stays sent. */
|
|
169
|
+
export async function cancelShare(runId, jobId) {
|
|
170
|
+
const job = workingJob(runId);
|
|
171
|
+
if (!job || (jobId && job.jobId !== jobId)) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
job.abort.abort();
|
|
175
|
+
await finishJob(runId, job.jobId, { status: "cancelled" });
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
/** The choices of a form field that come from the connected account (Slack's channels). */
|
|
179
|
+
export async function shareChoices(run, key, field, who) {
|
|
180
|
+
const projectId = await projectIdOf(run);
|
|
181
|
+
const entry = await entryOf(run, projectId, key, who.member);
|
|
182
|
+
if (!entry.target.choices) {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
return entry.target.choices(field, contextOf(run, projectId, entry, who, null));
|
|
186
|
+
}
|