wizards 0.2.2 → 0.2.4
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/apps/runtime/dist/app.js +44 -0
- package/apps/runtime/dist/auth/account.js +16 -2
- package/apps/runtime/dist/auth/index.js +14 -1
- package/apps/runtime/dist/connectors/external.js +5 -0
- package/apps/runtime/dist/db/client.js +24 -3
- package/apps/runtime/dist/db/control-schema.js +12 -3
- package/apps/runtime/dist/db/migrations/control/0005_chilly_oracle.sql +1 -0
- package/apps/runtime/dist/db/migrations/control/0006_sleepy_mojo.sql +1 -0
- package/apps/runtime/dist/db/migrations/control/meta/0005_snapshot.json +358 -0
- package/apps/runtime/dist/db/migrations/control/meta/0006_snapshot.json +365 -0
- package/apps/runtime/dist/db/migrations/control/meta/_journal.json +14 -0
- package/apps/runtime/dist/db/migrations/tenant/0005_reflective_forgotten_one.sql +2 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/0005_snapshot.json +1659 -0
- package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
- package/apps/runtime/dist/db/schema.js +6 -0
- package/apps/runtime/dist/engine/asks.js +2 -0
- package/apps/runtime/dist/engine/push.js +58 -0
- package/apps/runtime/dist/engine/runner.js +6 -0
- package/apps/runtime/dist/env.js +13 -0
- package/apps/runtime/dist/index.js +6 -0
- package/apps/runtime/dist/limits.js +21 -0
- package/apps/runtime/dist/manage.js +16 -3
- package/apps/runtime/dist/mcp/tools.js +4 -2
- package/apps/runtime/dist/routes/api.js +96 -6
- package/apps/runtime/dist/routes/runs.js +68 -2
- package/apps/runtime/dist/routes/studio.js +79 -11
- package/apps/runtime/dist/services/access.js +62 -0
- package/apps/runtime/dist/services/architect.js +3 -2
- package/apps/runtime/dist/services/cloud.js +298 -39
- package/apps/runtime/dist/services/errors.js +1 -0
- package/apps/runtime/dist/services/files.js +53 -4
- package/apps/runtime/dist/services/project-files.js +6 -0
- package/apps/runtime/dist/services/projects.js +38 -14
- package/apps/runtime/dist/services/spaces.js +400 -0
- package/apps/runtime/dist/services/wizards.js +19 -8
- package/apps/runtime/dist/tenants/control.js +47 -0
- package/apps/runtime/dist/update.js +3 -3
- package/apps/web/dist/assets/{ChangelogDialog-4HCt88rf.js → ChangelogDialog-lGa0JKY1.js} +1 -1
- package/apps/web/dist/assets/ChatPanel-Dk_45uca.js +1 -0
- package/apps/web/dist/assets/{CreditsDialog-D3fZRqXH.js → CreditsDialog-BPMWo6tp.js} +1 -1
- package/apps/web/dist/assets/EditorPage-Dt4MCcrU.js +2 -0
- package/apps/web/dist/assets/{FlowPreview-CjUK5046.js → FlowPreview-gwtUv8-X.js} +1 -1
- package/apps/web/dist/assets/HomePage-Bi9FK3_1.js +1 -0
- package/apps/web/dist/assets/ImportWizard-D6y4bKlF.js +1 -0
- package/apps/web/dist/assets/{LocalRuntime-DupxjXff.js → LocalRuntime-BjzPSsHr.js} +10 -10
- package/apps/web/dist/assets/NewWizardPage-2HbCaPSt.js +2 -0
- package/apps/web/dist/assets/PublicRunner-Dac_e6wg.js +1 -0
- package/apps/web/dist/assets/RunnerView-BC6crf1p.js +2 -0
- package/apps/web/dist/assets/SettingsPage-BfXxa4SU.js +2 -0
- package/apps/web/dist/assets/{SetupPage-xiqJMR0J.js → SetupPage-CYKVspBJ.js} +1 -1
- package/apps/web/dist/assets/{SharePage-C8B9BUSU.js → SharePage-DFIHsNS-.js} +1 -1
- package/apps/web/dist/assets/{api-KMi35npD.js → api-BTfKh3La.js} +1 -1
- package/apps/web/dist/assets/changelog-CLmll9By.js +1 -0
- package/apps/web/dist/assets/index-CIwJmOjo.css +1 -0
- package/apps/web/dist/assets/{index-2Ktgo6f1.js → index-DOpyzQiU.js} +3 -3
- package/apps/web/dist/assets/{meta-4R1Lnr25.js → meta-BbVhbYf6.js} +1 -1
- package/apps/web/dist/assets/oss-credits-Hu4LieLA.js +1 -0
- package/apps/web/dist/assets/outputs-n_yhxmMk.js +69 -0
- package/apps/web/dist/assets/{session-B6-6VomQ.js → session-D_Zggq_t.js} +1 -1
- package/apps/web/dist/assets/type-BMJpS6_D.js +1 -0
- package/apps/web/dist/assets/ui-C3bTWmdM.js +1 -0
- package/apps/web/dist/index.html +5 -5
- package/apps/web/dist/mcp/flow.html +54 -54
- package/package.json +1 -1
- package/apps/web/dist/assets/ChatPanel-B91fTrzg.js +0 -1
- package/apps/web/dist/assets/EditorPage-ZZjb9mGV.js +0 -2
- package/apps/web/dist/assets/HomePage-Cn5TCZk7.js +0 -1
- package/apps/web/dist/assets/ImportWizard-DiV62Uno.js +0 -1
- package/apps/web/dist/assets/NewWizardPage-Bz3PIQGp.js +0 -2
- package/apps/web/dist/assets/PublicRunner-Axs1zwqe.js +0 -1
- package/apps/web/dist/assets/RunnerView-Bhv6RNME.js +0 -2
- package/apps/web/dist/assets/SettingsPage-in_RmI8i.js +0 -2
- package/apps/web/dist/assets/changelog-C2LDEzLQ.js +0 -1
- package/apps/web/dist/assets/index-B9YVjvVY.css +0 -1
- package/apps/web/dist/assets/oss-credits-roB62QJu.js +0 -1
- package/apps/web/dist/assets/outputs-Dhqtf-SA.js +0 -69
- package/apps/web/dist/assets/type-Bj9H3eOK.js +0 -1
- package/apps/web/dist/assets/ui-Cs6lpsmj.js +0 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { cleanPath, isTextMime, mimeForPath, WORKSPACE_LIMITS, } from "@engenty-wizards/shared/workspace";
|
|
2
|
-
import { and, asc, eq } from "drizzle-orm";
|
|
2
|
+
import { and, asc, eq, inArray } from "drizzle-orm";
|
|
3
3
|
import { db, schema } from "../db/client.js";
|
|
4
4
|
import { getBlob, putBlob } from "../files/blobs.js";
|
|
5
5
|
import { emitDraftChanged, filesChanged } from "./draft-events.js";
|
|
6
6
|
import { notFound, ServiceError } from "./errors.js";
|
|
7
|
-
import { ownedWizard } from "./wizards.js";
|
|
7
|
+
import { ownedWizard, writableWizard } from "./wizards.js";
|
|
8
8
|
/** The draft's workspace, sorted by path — the same shape publishing and runs snapshot. */
|
|
9
9
|
export async function draftFiles(wizardId) {
|
|
10
10
|
const rows = await db.query.wizardFile.findMany({
|
|
@@ -44,7 +44,7 @@ export async function readFileText(userId, wizardId, rawPath) {
|
|
|
44
44
|
return { path: file.path, mime: file.mime, size: file.size, text: data.toString("utf8") };
|
|
45
45
|
}
|
|
46
46
|
export async function writeFile(userId, wizardId, rawPath, content, mime) {
|
|
47
|
-
const w = await
|
|
47
|
+
const w = await writableWizard(userId, wizardId);
|
|
48
48
|
const path = requirePath(rawPath);
|
|
49
49
|
const data = typeof content === "string" ? Buffer.from(content, "utf8") : Buffer.from(content);
|
|
50
50
|
if (data.byteLength > WORKSPACE_LIMITS.fileBytes) {
|
|
@@ -76,8 +76,57 @@ export async function writeFile(userId, wizardId, rawPath, content, mime) {
|
|
|
76
76
|
emitDraftChanged(filesChanged(w, path));
|
|
77
77
|
return file;
|
|
78
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Keeps the bytes of files that are about to become a wizard's workspace, checked against the
|
|
81
|
+
* workspace limits, and says how their rows look — without touching the wizard yet. Bytes that
|
|
82
|
+
* nothing comes to refer to take no harm.
|
|
83
|
+
*/
|
|
84
|
+
export async function keepFiles(files) {
|
|
85
|
+
if (files.length > WORKSPACE_LIMITS.files) {
|
|
86
|
+
throw new ServiceError("invalid", `A workspace holds up to ${WORKSPACE_LIMITS.files} files.`);
|
|
87
|
+
}
|
|
88
|
+
let total = 0;
|
|
89
|
+
const kept = [];
|
|
90
|
+
for (const file of files) {
|
|
91
|
+
const path = requirePath(file.path);
|
|
92
|
+
if (file.data.byteLength > WORKSPACE_LIMITS.fileBytes) {
|
|
93
|
+
throw new ServiceError("invalid", `${path} is larger than 5 MB.`);
|
|
94
|
+
}
|
|
95
|
+
total += file.data.byteLength;
|
|
96
|
+
if (total > WORKSPACE_LIMITS.totalBytes) {
|
|
97
|
+
throw new ServiceError("invalid", "The workspace would be larger than 25 MB.");
|
|
98
|
+
}
|
|
99
|
+
kept.push({
|
|
100
|
+
path,
|
|
101
|
+
hash: await putBlob(file.data),
|
|
102
|
+
mime: file.mime && file.mime !== "application/octet-stream" ? file.mime : mimeForPath(path),
|
|
103
|
+
size: file.data.byteLength,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return kept;
|
|
107
|
+
}
|
|
108
|
+
/** Makes a wizard's workspace exactly `files` (kept with keepFiles): rows only, no bytes. */
|
|
109
|
+
export async function replaceFiles(wizardId, files) {
|
|
110
|
+
const paths = files.map((f) => f.path);
|
|
111
|
+
const before = await draftFiles(wizardId);
|
|
112
|
+
const gone = before.filter((f) => !paths.includes(f.path)).map((f) => f.path);
|
|
113
|
+
if (gone.length) {
|
|
114
|
+
await db
|
|
115
|
+
.delete(schema.wizardFile)
|
|
116
|
+
.where(and(eq(schema.wizardFile.wizardId, wizardId), inArray(schema.wizardFile.path, gone)));
|
|
117
|
+
}
|
|
118
|
+
for (const file of files) {
|
|
119
|
+
await db
|
|
120
|
+
.insert(schema.wizardFile)
|
|
121
|
+
.values({ wizardId, ...file })
|
|
122
|
+
.onConflictDoUpdate({
|
|
123
|
+
target: [schema.wizardFile.wizardId, schema.wizardFile.path],
|
|
124
|
+
set: { hash: file.hash, mime: file.mime, size: file.size, updatedAt: new Date() },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
79
128
|
export async function deleteFile(userId, wizardId, rawPath) {
|
|
80
|
-
const w = await
|
|
129
|
+
const w = await writableWizard(userId, wizardId);
|
|
81
130
|
const path = requirePath(rawPath);
|
|
82
131
|
const gone = await db
|
|
83
132
|
.delete(schema.wizardFile)
|
|
@@ -6,6 +6,7 @@ import { documentMime, UnreadableDocument } from "../documents/parse.js";
|
|
|
6
6
|
import { loadAsset, removeAsset, saveAsset } from "../files/storage.js";
|
|
7
7
|
import { textModel } from "../models.js";
|
|
8
8
|
import { dropLinks, putLink } from "../tenants/control.js";
|
|
9
|
+
import { requireWritableProject } from "./access.js";
|
|
9
10
|
import { notFound, ServiceError } from "./errors.js";
|
|
10
11
|
import { dropIndex, indexDocument } from "./project-index.js";
|
|
11
12
|
/** Reading a file takes seconds to a few minutes; a row still `pending` after this was cut off. */
|
|
@@ -64,6 +65,7 @@ function accepts(kind, mime) {
|
|
|
64
65
|
return true;
|
|
65
66
|
}
|
|
66
67
|
export async function addProjectFile(projectId, input) {
|
|
68
|
+
await requireWritableProject(projectId);
|
|
67
69
|
const mime = documentMime(input.name, input.mime) || "application/octet-stream";
|
|
68
70
|
if (!accepts(input.kind, mime)) {
|
|
69
71
|
throw new ServiceError("invalid", input.kind === "logo"
|
|
@@ -208,6 +210,7 @@ async function prepare(row) {
|
|
|
208
210
|
});
|
|
209
211
|
}
|
|
210
212
|
export async function reindexProjectFile(projectId, fileId) {
|
|
213
|
+
await requireWritableProject(projectId);
|
|
211
214
|
const row = await projectFile(projectId, fileId);
|
|
212
215
|
if (row.kind !== "document") {
|
|
213
216
|
throw new ServiceError("invalid", "Nur Dokumente stehen im Index.");
|
|
@@ -221,6 +224,7 @@ export async function reindexProjectFile(projectId, fileId) {
|
|
|
221
224
|
return pending;
|
|
222
225
|
}
|
|
223
226
|
export async function updateProjectFile(projectId, fileId, patch) {
|
|
227
|
+
await requireWritableProject(projectId);
|
|
224
228
|
const row = await projectFile(projectId, fileId);
|
|
225
229
|
const [updated] = await db
|
|
226
230
|
.update(schema.projectFile)
|
|
@@ -236,6 +240,7 @@ export async function updateProjectFile(projectId, fileId, patch) {
|
|
|
236
240
|
}
|
|
237
241
|
/** Puts the files of one kind into the order of `ids`; the first logo is the one end users see. */
|
|
238
242
|
export async function orderProjectFiles(projectId, kind, ids) {
|
|
243
|
+
await requireWritableProject(projectId);
|
|
239
244
|
const rows = await projectFiles(projectId, kind);
|
|
240
245
|
const order = [...ids.filter((id) => rows.some((r) => r.id === id))];
|
|
241
246
|
for (const r of rows) {
|
|
@@ -256,6 +261,7 @@ async function removeRow(row) {
|
|
|
256
261
|
}
|
|
257
262
|
}
|
|
258
263
|
export async function removeProjectFile(projectId, fileId) {
|
|
264
|
+
await requireWritableProject(projectId);
|
|
259
265
|
await removeRow(await projectFile(projectId, fileId));
|
|
260
266
|
}
|
|
261
267
|
/** Before a project goes: its files leave the object store and the public links. */
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { FACT_TYPES, PROJECT_LIMITS } from "@engenty-wizards/shared/projects";
|
|
2
|
-
import { and, count, eq } from "drizzle-orm";
|
|
2
|
+
import { and, count, eq, isNull } from "drizzle-orm";
|
|
3
3
|
import { nanoid } from "nanoid";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { db, schema } from "../db/client.js";
|
|
6
6
|
import { env } from "../env.js";
|
|
7
7
|
import { managed, tenantInfo } from "../manage.js";
|
|
8
8
|
import { currentTenant } from "../tenants/tenant.js";
|
|
9
|
+
import { mayBuild, requireBuild, requireWritable } from "./access.js";
|
|
9
10
|
import { notFound, ServiceError } from "./errors.js";
|
|
10
11
|
import { forgetWizardLinks } from "./links.js";
|
|
11
12
|
import { removeProjectFiles } from "./project-files.js";
|
|
@@ -61,22 +62,33 @@ export async function ownedProject(_userId, projectId) {
|
|
|
61
62
|
}
|
|
62
63
|
return p;
|
|
63
64
|
}
|
|
64
|
-
/**
|
|
65
|
+
/** The tenant's projects that were made here, not synced from a local install. */
|
|
66
|
+
const madeHere = () => and(eq(schema.project.tenantId, currentTenant()), isNull(schema.project.origin));
|
|
67
|
+
/** The tenant's projects a local install synced. */
|
|
68
|
+
const synced = () => and(eq(schema.project.tenantId, currentTenant()), eq(schema.project.origin, "local"));
|
|
69
|
+
/**
|
|
70
|
+
* A tenant that builds here always has a project of its own: the first one is made when its
|
|
71
|
+
* studio is first opened.
|
|
72
|
+
*/
|
|
65
73
|
async function ensureProject() {
|
|
66
|
-
const any = await db.query.project.findFirst({
|
|
67
|
-
where: eq(schema.project.tenantId, currentTenant()),
|
|
68
|
-
});
|
|
74
|
+
const any = await db.query.project.findFirst({ where: madeHere() });
|
|
69
75
|
if (!any) {
|
|
70
76
|
await db
|
|
71
77
|
.insert(schema.project)
|
|
72
78
|
.values({ id: nanoid(12), tenantId: currentTenant(), name: "Meine Wizards" });
|
|
73
79
|
}
|
|
74
80
|
}
|
|
75
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* The project a caller means when it names none: the oldest one made here, where a wizard
|
|
83
|
+
* lands; for a tenant that builds nothing here, the one its local install synced.
|
|
84
|
+
*/
|
|
76
85
|
export async function defaultProject(_userId) {
|
|
77
|
-
await
|
|
86
|
+
const build = await mayBuild();
|
|
87
|
+
if (build) {
|
|
88
|
+
await ensureProject();
|
|
89
|
+
}
|
|
78
90
|
const p = await db.query.project.findFirst({
|
|
79
|
-
where:
|
|
91
|
+
where: build ? madeHere() : synced(),
|
|
80
92
|
orderBy: [schema.project.createdAt],
|
|
81
93
|
});
|
|
82
94
|
if (!p) {
|
|
@@ -84,11 +96,18 @@ export async function defaultProject(_userId) {
|
|
|
84
96
|
}
|
|
85
97
|
return p;
|
|
86
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* The projects the studio shows. A tenant that builds here: its own and the synced ones, up to
|
|
101
|
+
* its number. One that does not: only what its local install synced — possibly nothing yet.
|
|
102
|
+
*/
|
|
87
103
|
export async function listProjects(_userId) {
|
|
88
|
-
await
|
|
104
|
+
const build = await mayBuild();
|
|
105
|
+
if (build) {
|
|
106
|
+
await ensureProject();
|
|
107
|
+
}
|
|
89
108
|
// The oldest come first: with a limit of one, that is the project everything lands in.
|
|
90
109
|
const projects = await db.query.project.findMany({
|
|
91
|
-
where: eq(schema.project.tenantId, currentTenant()),
|
|
110
|
+
where: build ? eq(schema.project.tenantId, currentTenant()) : synced(),
|
|
92
111
|
orderBy: [schema.project.createdAt],
|
|
93
112
|
limit: await projectLimit(),
|
|
94
113
|
});
|
|
@@ -112,6 +131,7 @@ export function maskedServers(p) {
|
|
|
112
131
|
}));
|
|
113
132
|
}
|
|
114
133
|
export async function createProject(_userId, name) {
|
|
134
|
+
await requireBuild();
|
|
115
135
|
const [{ n }] = await db
|
|
116
136
|
.select({ n: count() })
|
|
117
137
|
.from(schema.project)
|
|
@@ -128,6 +148,7 @@ export async function createProject(_userId, name) {
|
|
|
128
148
|
}
|
|
129
149
|
export async function updateProject(userId, projectId, patch) {
|
|
130
150
|
const p = await ownedProject(userId, projectId);
|
|
151
|
+
await requireWritable(p);
|
|
131
152
|
const mcpServers = patch.mcpServers?.map((s) => {
|
|
132
153
|
const before = p.mcpServers.find((x) => x.id === s.id);
|
|
133
154
|
const headers = s.headers
|
|
@@ -160,13 +181,16 @@ export function projectProfile(p) {
|
|
|
160
181
|
facts: p.facts.filter((f) => f.value.trim()),
|
|
161
182
|
};
|
|
162
183
|
}
|
|
163
|
-
export async function deleteProject(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
});
|
|
184
|
+
export async function deleteProject(userId, projectId) {
|
|
185
|
+
await requireWritable(await ownedProject(userId, projectId));
|
|
186
|
+
const all = await db.query.project.findMany({ where: madeHere() });
|
|
167
187
|
if (all.length <= 1) {
|
|
168
188
|
throw new ServiceError("refused", "Das letzte Projekt bleibt.");
|
|
169
189
|
}
|
|
190
|
+
await removeProject(projectId);
|
|
191
|
+
}
|
|
192
|
+
/** Takes a project away with its wizards' links and its files. */
|
|
193
|
+
export async function removeProject(projectId) {
|
|
170
194
|
const wizards = await db
|
|
171
195
|
.select({ id: schema.wizard.id })
|
|
172
196
|
.from(schema.wizard)
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { PROJECT_LIMITS } from "@engenty-wizards/shared/projects";
|
|
3
|
+
import { WORKSPACE_LIMITS } from "@engenty-wizards/shared/workspace";
|
|
4
|
+
import { and, count, eq, inArray, sql } from "drizzle-orm";
|
|
5
|
+
import { nanoid } from "nanoid";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { db, inTransaction, schema } from "../db/client.js";
|
|
8
|
+
import { missingModels, unavoidableSteps } from "../engine/requirements.js";
|
|
9
|
+
import { env } from "../env.js";
|
|
10
|
+
import { codeOf, putLink } from "../tenants/control.js";
|
|
11
|
+
import { currentTenant } from "../tenants/tenant.js";
|
|
12
|
+
import { asSync, mayBuild } from "./access.js";
|
|
13
|
+
import { emitDraftChanged } from "./draft-events.js";
|
|
14
|
+
import { notFound, ServiceError } from "./errors.js";
|
|
15
|
+
import { keepFiles, replaceFiles } from "./files.js";
|
|
16
|
+
import { addProjectFile, projectFiles, removeProjectFile } from "./project-files.js";
|
|
17
|
+
import { brandColorSchema, projectFactSchema, projectLimit, removeProject, } from "./projects.js";
|
|
18
|
+
import { deleteWizard, draftIssues, parseDraft, shareUrl } from "./wizards.js";
|
|
19
|
+
/**
|
|
20
|
+
* A local install's project on this runtime (docs/manage-contract.md, "Spaces of a local
|
|
21
|
+
* install"). The install sends a wizard when it publishes it: the published version and its
|
|
22
|
+
* workspace, under the ids they have there. Here the project is shown and its wizards run; it
|
|
23
|
+
* is changed only by the next sync. The share link is this runtime's own.
|
|
24
|
+
*/
|
|
25
|
+
/** Ids as the local install makes them (nanoid); nothing else is taken as a key. */
|
|
26
|
+
const syncedId = z.string().regex(/^[A-Za-z0-9_-]{8,40}$/);
|
|
27
|
+
const spaceSchema = z.object({
|
|
28
|
+
name: z.string().min(1).max(80),
|
|
29
|
+
brand: z
|
|
30
|
+
.object({
|
|
31
|
+
name: z.string().max(120).optional(),
|
|
32
|
+
about: z.string().max(8000).optional(),
|
|
33
|
+
colors: z.array(brandColorSchema).max(PROJECT_LIMITS.colors).optional(),
|
|
34
|
+
})
|
|
35
|
+
.default({}),
|
|
36
|
+
facts: z.array(projectFactSchema).max(PROJECT_LIMITS.facts).default([]),
|
|
37
|
+
/** The logo end users see; null for none. */
|
|
38
|
+
logo: z
|
|
39
|
+
.object({
|
|
40
|
+
name: z.string().min(1).max(120),
|
|
41
|
+
mime: z.string().max(100),
|
|
42
|
+
description: z.string().max(600).default(""),
|
|
43
|
+
data: z.string(),
|
|
44
|
+
})
|
|
45
|
+
.nullable()
|
|
46
|
+
.default(null),
|
|
47
|
+
});
|
|
48
|
+
export const syncWizardSchema = z.object({
|
|
49
|
+
space: spaceSchema,
|
|
50
|
+
/** The version the install published, as it counts them. */
|
|
51
|
+
version: z.number().int().min(1).max(1_000_000),
|
|
52
|
+
definition: z.unknown(),
|
|
53
|
+
files: z
|
|
54
|
+
.array(z.object({
|
|
55
|
+
path: z.string().max(300),
|
|
56
|
+
mime: z.string().max(100).optional(),
|
|
57
|
+
data: z.string(),
|
|
58
|
+
}))
|
|
59
|
+
.max(WORKSPACE_LIMITS.files)
|
|
60
|
+
.default([]),
|
|
61
|
+
shareEnabled: z.boolean().default(true),
|
|
62
|
+
dailyRunLimit: z.number().int().min(1).max(10_000).optional(),
|
|
63
|
+
});
|
|
64
|
+
export const syncSettingsSchema = z.object({
|
|
65
|
+
shareEnabled: z.boolean().optional(),
|
|
66
|
+
dailyRunLimit: z.number().int().min(1).max(10_000).optional(),
|
|
67
|
+
});
|
|
68
|
+
export const checkSchema = z.object({
|
|
69
|
+
definition: z.unknown(),
|
|
70
|
+
/** Paths of the wizard's workspace files. */
|
|
71
|
+
files: z.array(z.string().max(300)).max(WORKSPACE_LIMITS.files).default([]),
|
|
72
|
+
});
|
|
73
|
+
const stepsWith = (def, has) => def.steps.filter(has).map((s) => ({ id: s.id, title: s.title }));
|
|
74
|
+
/**
|
|
75
|
+
* What of a wizard does not work on this runtime: what its validator says here (a connector
|
|
76
|
+
* the project lacks, a plugin's tool), model classes nothing is bound to, the sandbox where
|
|
77
|
+
* there is none, MCP servers the project does not know.
|
|
78
|
+
*/
|
|
79
|
+
export async function serverProblems(def, issues, mcpServers) {
|
|
80
|
+
const problems = issues.map((issue) => ({
|
|
81
|
+
code: /connector/i.test(issue.message) ? "connector" : "invalid",
|
|
82
|
+
blocking: true,
|
|
83
|
+
steps: stepsWith(def, (s) => s.id === issue.stepId),
|
|
84
|
+
detail: issue.message,
|
|
85
|
+
}));
|
|
86
|
+
for (const missing of await missingModels(def)) {
|
|
87
|
+
problems.push({
|
|
88
|
+
code: "model",
|
|
89
|
+
blocking: missing.blocking,
|
|
90
|
+
steps: missing.steps,
|
|
91
|
+
detail: `${missing.cls}: ${missing.problem}`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (env.sandbox === "off") {
|
|
95
|
+
const steps = stepsWith(def, (s) => s.type === "agent" && s.tools.includes("sandbox"));
|
|
96
|
+
if (steps.length) {
|
|
97
|
+
problems.push({ code: "sandbox", blocking: false, steps, detail: "" });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const unknown = new Set();
|
|
101
|
+
const steps = stepsWith(def, (s) => {
|
|
102
|
+
const lacking = s.type === "agent" ? (s.mcp ?? []).filter((id) => !mcpServers.includes(id)) : [];
|
|
103
|
+
for (const id of lacking) {
|
|
104
|
+
unknown.add(id);
|
|
105
|
+
}
|
|
106
|
+
return lacking.length > 0;
|
|
107
|
+
});
|
|
108
|
+
if (steps.length) {
|
|
109
|
+
const unavoidable = unavoidableSteps(def);
|
|
110
|
+
problems.push({
|
|
111
|
+
code: "mcp",
|
|
112
|
+
blocking: steps.some((s) => unavoidable.has(s.id)),
|
|
113
|
+
steps,
|
|
114
|
+
detail: [...unknown].join(", "),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return problems;
|
|
118
|
+
}
|
|
119
|
+
/** What a wizard would lack here, asked before it is sent: no project is touched. */
|
|
120
|
+
export async function checkOnServer(input) {
|
|
121
|
+
const { draft, issues } = parseDraft(input.definition, input.files);
|
|
122
|
+
// Connectors are a project's; a project that is not here yet has none.
|
|
123
|
+
const connectors = (draft.connections ?? [])
|
|
124
|
+
.filter((c) => c.connector)
|
|
125
|
+
.map((c) => ({
|
|
126
|
+
message: `Connection "${c.id}" uses connector "${c.connector}", which this project has not imported.`,
|
|
127
|
+
}));
|
|
128
|
+
return serverProblems(draft, [...issues, ...connectors], []);
|
|
129
|
+
}
|
|
130
|
+
async function syncedProject(spaceId) {
|
|
131
|
+
const p = await db.query.project.findFirst({
|
|
132
|
+
where: and(eq(schema.project.id, spaceId), eq(schema.project.tenantId, currentTenant())),
|
|
133
|
+
});
|
|
134
|
+
return p ?? null;
|
|
135
|
+
}
|
|
136
|
+
/** Refuses a project beyond the tenant's number; a synced one counts like one made here. */
|
|
137
|
+
async function requireRoom(spaceId) {
|
|
138
|
+
const mine = eq(schema.project.tenantId, currentTenant());
|
|
139
|
+
// What a tenant that builds nothing here once made here is not shown, so it takes no place.
|
|
140
|
+
const counted = (await mayBuild()) ? mine : and(mine, eq(schema.project.origin, "local"));
|
|
141
|
+
const others = await db
|
|
142
|
+
.select({ id: schema.project.id, name: schema.project.name, origin: schema.project.origin })
|
|
143
|
+
.from(schema.project)
|
|
144
|
+
.where(and(counted, sql `${schema.project.id} <> ${spaceId}`));
|
|
145
|
+
const limit = await projectLimit();
|
|
146
|
+
if (others.length >= limit) {
|
|
147
|
+
throw new ServiceError("refused", limit === 1
|
|
148
|
+
? "Dieses Konto hat hier schon ein Projekt."
|
|
149
|
+
: `Dieses Konto hat hier schon ${limit} Projekte.`, {
|
|
150
|
+
reason: "space_limit",
|
|
151
|
+
limit,
|
|
152
|
+
spaces: others.filter((p) => p.origin === "local").map((p) => ({ id: p.id, name: p.name })),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** A synced project holds so many wizards: each brings up to 25 MB of files to keep. */
|
|
157
|
+
async function requireWizardRoom(spaceId) {
|
|
158
|
+
const [row] = await db
|
|
159
|
+
.select({ n: count() })
|
|
160
|
+
.from(schema.wizard)
|
|
161
|
+
.where(eq(schema.wizard.projectId, spaceId));
|
|
162
|
+
if ((row?.n ?? 0) >= env.limits.syncedWizards) {
|
|
163
|
+
throw new ServiceError("refused", `Hier haben höchstens ${env.limits.syncedWizards} veröffentlichte Wizards Platz.`, { reason: "wizard_limit", limit: env.limits.syncedWizards });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const hashOf = (data) => createHash("sha256").update(data).digest("hex");
|
|
167
|
+
/**
|
|
168
|
+
* The project's logo follows the install's: kept when it is the same picture, else replaced —
|
|
169
|
+
* the new one is stored before the old one goes, so a store that fails leaves the old one.
|
|
170
|
+
*/
|
|
171
|
+
async function syncLogo(projectId, logo) {
|
|
172
|
+
const here = await projectFiles(projectId, "logo");
|
|
173
|
+
const data = logo ? Buffer.from(logo.data, "base64") : null;
|
|
174
|
+
const mark = data ? `sync:${hashOf(data)}` : null;
|
|
175
|
+
if (mark && here.length === 1 && here[0].source === mark) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (logo && data && mark) {
|
|
179
|
+
await addProjectFile(projectId, {
|
|
180
|
+
kind: "logo",
|
|
181
|
+
name: logo.name,
|
|
182
|
+
mime: logo.mime,
|
|
183
|
+
data,
|
|
184
|
+
// With words of its own no model is asked to describe it.
|
|
185
|
+
description: logo.description || logo.name,
|
|
186
|
+
source: mark,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
for (const old of here) {
|
|
190
|
+
await removeProjectFile(projectId, old.id);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/** Makes or updates the synced project: its name and what it says about itself (the logo: syncLogo). */
|
|
194
|
+
async function syncSpace(spaceId, space) {
|
|
195
|
+
const existing = await syncedProject(spaceId);
|
|
196
|
+
if (existing && existing.origin !== "local") {
|
|
197
|
+
throw new ServiceError("refused", "Ein Projekt mit dieser Kennung wurde hier angelegt.", {
|
|
198
|
+
reason: "id_taken",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
if (!existing) {
|
|
202
|
+
await requireRoom(spaceId);
|
|
203
|
+
}
|
|
204
|
+
const now = new Date();
|
|
205
|
+
const values = { name: space.name, brand: space.brand, facts: space.facts, syncedAt: now };
|
|
206
|
+
if (existing) {
|
|
207
|
+
await db
|
|
208
|
+
.update(schema.project)
|
|
209
|
+
.set({ ...values, updatedAt: now })
|
|
210
|
+
.where(eq(schema.project.id, spaceId));
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
await db.insert(schema.project).values({
|
|
214
|
+
id: spaceId,
|
|
215
|
+
tenantId: currentTenant(),
|
|
216
|
+
origin: "local",
|
|
217
|
+
mcpServers: [],
|
|
218
|
+
...values,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const project = await syncedProject(spaceId);
|
|
222
|
+
if (!project) {
|
|
223
|
+
throw notFound();
|
|
224
|
+
}
|
|
225
|
+
return project;
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Takes a wizard a local install published: the project it is in, the wizard under its id, its
|
|
229
|
+
* workspace, and the version — published here when a run of it can finish. Sending the same
|
|
230
|
+
* wizard again updates the copy; its link stays. The files' bytes are kept first; then the
|
|
231
|
+
* project, the wizard, its workspace and the version are written in one transaction, so an
|
|
232
|
+
* install whose sending broke off finds nothing half done and sends again.
|
|
233
|
+
*/
|
|
234
|
+
export async function syncWizard(rawSpaceId, rawWizardId, input) {
|
|
235
|
+
const spaceId = syncedId.parse(rawSpaceId);
|
|
236
|
+
const wizardId = syncedId.parse(rawWizardId);
|
|
237
|
+
// The shape is checked before anything is written; a wrong one leaves nothing behind.
|
|
238
|
+
const { draft } = parseDraft(input.definition, input.files.map((f) => f.path));
|
|
239
|
+
return asSync(async () => {
|
|
240
|
+
const files = await keepFiles(input.files.map((f) => ({ path: f.path, mime: f.mime, data: Buffer.from(f.data, "base64") })));
|
|
241
|
+
const taken = await inTransaction(async () => {
|
|
242
|
+
const project = await syncSpace(spaceId, input.space);
|
|
243
|
+
const existing = await db.query.wizard.findFirst({
|
|
244
|
+
where: and(eq(schema.wizard.id, wizardId), eq(schema.wizard.tenantId, currentTenant())),
|
|
245
|
+
});
|
|
246
|
+
if (existing && existing.projectId !== spaceId) {
|
|
247
|
+
throw new ServiceError("refused", "Ein Wizard mit dieser Kennung gehört hier woanders hin.", {
|
|
248
|
+
reason: "id_taken",
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const settings = {
|
|
252
|
+
title: draft.title,
|
|
253
|
+
draft,
|
|
254
|
+
shareEnabled: input.shareEnabled,
|
|
255
|
+
...(input.dailyRunLimit ? { dailyRunLimit: input.dailyRunLimit } : {}),
|
|
256
|
+
};
|
|
257
|
+
if (!existing) {
|
|
258
|
+
await requireWizardRoom(spaceId);
|
|
259
|
+
}
|
|
260
|
+
if (existing) {
|
|
261
|
+
await db
|
|
262
|
+
.update(schema.wizard)
|
|
263
|
+
.set({ ...settings, revision: sql `${schema.wizard.revision} + 1`, updatedAt: new Date() })
|
|
264
|
+
.where(eq(schema.wizard.id, wizardId));
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
await db.insert(schema.wizard).values({
|
|
268
|
+
id: wizardId,
|
|
269
|
+
projectId: spaceId,
|
|
270
|
+
tenantId: currentTenant(),
|
|
271
|
+
// The link is made here: a token an install brought could be one another tenant holds.
|
|
272
|
+
shareToken: nanoid(14),
|
|
273
|
+
dailyRunLimit: env.limits.defaultDailyRuns,
|
|
274
|
+
...settings,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
// The workspace becomes what was sent: files that are gone there go here.
|
|
278
|
+
await replaceFiles(wizardId, files);
|
|
279
|
+
const row = await db.query.wizard.findFirst({ where: eq(schema.wizard.id, wizardId) });
|
|
280
|
+
if (!row) {
|
|
281
|
+
throw notFound();
|
|
282
|
+
}
|
|
283
|
+
const problems = await serverProblems(draft, await draftIssues(row), project.mcpServers.map((s) => s.id));
|
|
284
|
+
const runnable = !problems.some((p) => p.blocking);
|
|
285
|
+
let publishedVersion = row.publishedVersion;
|
|
286
|
+
if (runnable) {
|
|
287
|
+
await db
|
|
288
|
+
.insert(schema.wizardVersion)
|
|
289
|
+
.values({ id: nanoid(12), wizardId, version: input.version, definition: draft, files })
|
|
290
|
+
.onConflictDoUpdate({
|
|
291
|
+
target: [schema.wizardVersion.wizardId, schema.wizardVersion.version],
|
|
292
|
+
set: { definition: draft, files },
|
|
293
|
+
});
|
|
294
|
+
await db
|
|
295
|
+
.update(schema.wizard)
|
|
296
|
+
.set({ publishedVersion: input.version })
|
|
297
|
+
.where(eq(schema.wizard.id, wizardId));
|
|
298
|
+
publishedVersion = input.version;
|
|
299
|
+
}
|
|
300
|
+
// Last, since it reaches the object store: a store that fails takes everything back.
|
|
301
|
+
await syncLogo(spaceId, input.space.logo);
|
|
302
|
+
return { row, problems, runnable, publishedVersion };
|
|
303
|
+
});
|
|
304
|
+
const { row, problems, runnable, publishedVersion } = taken;
|
|
305
|
+
// The public link lives in the control database, outside the transaction: set on every
|
|
306
|
+
// sync, so one that failed to get there once is there after the next.
|
|
307
|
+
await putLink(row.shareToken, "wizard", wizardId);
|
|
308
|
+
emitDraftChanged({
|
|
309
|
+
wizardId,
|
|
310
|
+
kind: "draft",
|
|
311
|
+
revision: row.revision,
|
|
312
|
+
source: "mcp",
|
|
313
|
+
client: "sync",
|
|
314
|
+
note: null,
|
|
315
|
+
touched: [],
|
|
316
|
+
});
|
|
317
|
+
return {
|
|
318
|
+
wizardId,
|
|
319
|
+
shareUrl: shareUrl(row.shareToken),
|
|
320
|
+
code: await codeOf(row.shareToken),
|
|
321
|
+
shareEnabled: row.shareEnabled,
|
|
322
|
+
publishedVersion,
|
|
323
|
+
runnable,
|
|
324
|
+
problems,
|
|
325
|
+
};
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
async function syncedWizard(rawSpaceId, rawWizardId) {
|
|
329
|
+
const spaceId = syncedId.parse(rawSpaceId);
|
|
330
|
+
const wizardId = syncedId.parse(rawWizardId);
|
|
331
|
+
const project = await syncedProject(spaceId);
|
|
332
|
+
const w = project?.origin === "local"
|
|
333
|
+
? await db.query.wizard.findFirst({
|
|
334
|
+
where: and(eq(schema.wizard.id, wizardId), eq(schema.wizard.projectId, spaceId)),
|
|
335
|
+
})
|
|
336
|
+
: null;
|
|
337
|
+
if (!w) {
|
|
338
|
+
throw notFound();
|
|
339
|
+
}
|
|
340
|
+
return w;
|
|
341
|
+
}
|
|
342
|
+
/** The install changed how a synced wizard is shared: its link on or off, its runs a day. */
|
|
343
|
+
export async function patchSyncedWizard(spaceId, wizardId, patch) {
|
|
344
|
+
const w = await syncedWizard(spaceId, wizardId);
|
|
345
|
+
await db
|
|
346
|
+
.update(schema.wizard)
|
|
347
|
+
.set({ ...patch, updatedAt: new Date() })
|
|
348
|
+
.where(eq(schema.wizard.id, w.id));
|
|
349
|
+
return { ok: true };
|
|
350
|
+
}
|
|
351
|
+
/** The install deleted a wizard: its copy here goes, with its link and its runs. */
|
|
352
|
+
export async function removeSyncedWizard(userId, spaceId, wizardId) {
|
|
353
|
+
const w = await syncedWizard(spaceId, wizardId);
|
|
354
|
+
await asSync(() => deleteWizard(userId, w.id));
|
|
355
|
+
return { ok: true };
|
|
356
|
+
}
|
|
357
|
+
/** The projects local installs synced, with their wizards: what an install compares itself to. */
|
|
358
|
+
export async function listSyncedSpaces() {
|
|
359
|
+
const projects = await db.query.project.findMany({
|
|
360
|
+
where: and(eq(schema.project.tenantId, currentTenant()), eq(schema.project.origin, "local")),
|
|
361
|
+
orderBy: [schema.project.createdAt],
|
|
362
|
+
});
|
|
363
|
+
const wizards = projects.length
|
|
364
|
+
? await db
|
|
365
|
+
.select({
|
|
366
|
+
id: schema.wizard.id,
|
|
367
|
+
projectId: schema.wizard.projectId,
|
|
368
|
+
title: schema.wizard.title,
|
|
369
|
+
publishedVersion: schema.wizard.publishedVersion,
|
|
370
|
+
shareToken: schema.wizard.shareToken,
|
|
371
|
+
shareEnabled: schema.wizard.shareEnabled,
|
|
372
|
+
})
|
|
373
|
+
.from(schema.wizard)
|
|
374
|
+
.where(inArray(schema.wizard.projectId, projects.map((p) => p.id)))
|
|
375
|
+
: [];
|
|
376
|
+
return projects.map((p) => ({
|
|
377
|
+
id: p.id,
|
|
378
|
+
name: p.name,
|
|
379
|
+
syncedAt: p.syncedAt?.toISOString() ?? null,
|
|
380
|
+
wizards: wizards
|
|
381
|
+
.filter((w) => w.projectId === p.id)
|
|
382
|
+
.map((w) => ({
|
|
383
|
+
id: w.id,
|
|
384
|
+
title: w.title,
|
|
385
|
+
publishedVersion: w.publishedVersion,
|
|
386
|
+
shareUrl: shareUrl(w.shareToken),
|
|
387
|
+
shareEnabled: w.shareEnabled,
|
|
388
|
+
})),
|
|
389
|
+
}));
|
|
390
|
+
}
|
|
391
|
+
/** Takes a synced project away from this runtime: its wizards, their links and runs, its files. */
|
|
392
|
+
export async function removeSyncedSpace(rawSpaceId) {
|
|
393
|
+
const spaceId = syncedId.parse(rawSpaceId);
|
|
394
|
+
const project = await syncedProject(spaceId);
|
|
395
|
+
if (project?.origin !== "local") {
|
|
396
|
+
throw notFound();
|
|
397
|
+
}
|
|
398
|
+
await asSync(() => removeProject(spaceId));
|
|
399
|
+
return { ok: true };
|
|
400
|
+
}
|