wizards 0.2.1 → 0.2.3
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/README.md +4 -1
- 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/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 +14 -1
- 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/plugins/loader.js +23 -1
- 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 +241 -39
- package/apps/runtime/dist/services/errors.js +1 -0
- package/apps/runtime/dist/services/files.js +3 -3
- 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/web/dist/assets/{ChangelogDialog-Co_9fs61.js → ChangelogDialog-CbZBUMRu.js} +1 -1
- package/apps/web/dist/assets/ChatPanel-zurtfj6A.js +1 -0
- package/apps/web/dist/assets/{CreditsDialog-D3fZRqXH.js → CreditsDialog-BewZHZTR.js} +1 -1
- package/apps/web/dist/assets/EditorPage-B7ZOoW5h.js +2 -0
- package/apps/web/dist/assets/{FlowPreview-CjUK5046.js → FlowPreview-DHrhPps0.js} +1 -1
- package/apps/web/dist/assets/HomePage-CBvWxXsq.js +1 -0
- package/apps/web/dist/assets/ImportWizard-CKjVJMaG.js +1 -0
- package/apps/web/dist/assets/{LocalRuntime-CTyLGC98.js → LocalRuntime-Brkvbzzg.js} +10 -10
- package/apps/web/dist/assets/NewWizardPage-B3XePMlp.js +2 -0
- package/apps/web/dist/assets/PublicRunner-Bn1g6HG9.js +1 -0
- package/apps/web/dist/assets/RunnerView-CO1Nk3jn.js +2 -0
- package/apps/web/dist/assets/SettingsPage-BIY4kHZl.js +2 -0
- package/apps/web/dist/assets/{SetupPage-CfXTOaIm.js → SetupPage-BvLwZH9i.js} +1 -1
- package/apps/web/dist/assets/{SharePage-CgByqrAF.js → SharePage-CDESJ11B.js} +1 -1
- package/apps/web/dist/assets/{api-KMi35npD.js → api-9sHuNTkp.js} +1 -1
- package/apps/web/dist/assets/changelog-Cto0j5-V.js +1 -0
- package/apps/web/dist/assets/index-CIwJmOjo.css +1 -0
- package/apps/web/dist/assets/{index-BmpnE7IO.js → index-VsRgvimv.js} +3 -3
- package/apps/web/dist/assets/{meta-4R1Lnr25.js → meta-B4mZqEso.js} +1 -1
- package/apps/web/dist/assets/oss-credits-Hu4LieLA.js +1 -0
- package/apps/web/dist/assets/outputs-boPx5ngv.js +69 -0
- package/apps/web/dist/assets/{session-B6-6VomQ.js → session-CJGuo1Tv.js} +1 -1
- package/apps/web/dist/assets/type-BMJpS6_D.js +1 -0
- package/apps/web/dist/assets/ui-iTYHqwvY.js +1 -0
- package/apps/web/dist/index.html +5 -5
- package/apps/web/dist/mcp/flow.html +54 -54
- package/modules/README.md +1 -1
- package/node_modules/@engenty-wizards/plugin-sdk/dist/index.d.ts +24 -1
- package/node_modules/@engenty-wizards/plugin-sdk/dist/studio.d.ts +1 -1
- package/package.json +1 -1
- package/apps/web/dist/assets/ChatPanel-Br3nYkUc.js +0 -1
- package/apps/web/dist/assets/EditorPage-BSjQC6FE.js +0 -2
- package/apps/web/dist/assets/HomePage-8v4HqIbP.js +0 -1
- package/apps/web/dist/assets/ImportWizard-DiV62Uno.js +0 -1
- package/apps/web/dist/assets/NewWizardPage-BBL2yenw.js +0 -2
- package/apps/web/dist/assets/PublicRunner-BwtoEVlE.js +0 -1
- package/apps/web/dist/assets/RunnerView-DOizlTad.js +0 -2
- package/apps/web/dist/assets/SettingsPage-CBDv04YS.js +0 -2
- package/apps/web/dist/assets/changelog-03JgixQA.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-COE_nvAw.js +0 -69
- package/apps/web/dist/assets/type-Bj9H3eOK.js +0 -1
- package/apps/web/dist/assets/ui-Cs6lpsmj.js +0 -1
|
@@ -25,8 +25,9 @@ import { seenApps } from "../mcp/seen.js";
|
|
|
25
25
|
import { transcribeAudio } from "../media/transcribe.js";
|
|
26
26
|
import { embeddingModel, hasTextModel, LOCAL_KEYS, localModelSettings, ModelUnavailableError, saveLocalModels, textModel, } from "../models.js";
|
|
27
27
|
import { HTML_RESPONSE_CSP } from "../render/guard.js";
|
|
28
|
+
import { mayBuild, projectWritable, requireWritable } from "../services/access.js";
|
|
28
29
|
import { architectTurn } from "../services/architect.js";
|
|
29
|
-
import {
|
|
30
|
+
import { checkForCloud, cloudLinked, cloudSpaces, cloudState, forgetCloud, publishAndSync, pushSharing, removeFromCloud, replaceCloudSpaces, syncToCloud, } from "../services/cloud.js";
|
|
30
31
|
import { ServiceError } from "../services/errors.js";
|
|
31
32
|
import { deleteFile, listFiles, readFile, writeFile } from "../services/files.js";
|
|
32
33
|
import { exportWizard, importWizard, PACKAGE_EXTENSION, PACKAGE_MAX_BYTES, PACKAGE_MIME, } from "../services/package.js";
|
|
@@ -36,8 +37,9 @@ import { documentText, searchProject } from "../services/project-index.js";
|
|
|
36
37
|
import { createProject, deleteProject, listProjects, maskedServers, ownedProject, projectLimit, projectPatchSchema, updateProject, } from "../services/projects.js";
|
|
37
38
|
import { listRuns, startTestRun } from "../services/runs.js";
|
|
38
39
|
import { draftWidgetPreview } from "../services/widgets.js";
|
|
39
|
-
import { createWizard, deleteWizard, duplicateWizard, listWizards, ownedWizard,
|
|
40
|
+
import { createWizard, deleteWizard, duplicateWizard, listWizards, ownedWizard, rotateShareLink, updateWizardSettings, wizardMessages, wizardState, writableWizard, writeDraft, } from "../services/wizards.js";
|
|
40
41
|
import { readSetting, writeSetting } from "../settings.js";
|
|
42
|
+
import { codeOf } from "../tenants/control.js";
|
|
41
43
|
import { applyUpdate, updateStatus } from "../update.js";
|
|
42
44
|
import { byteRange } from "./delivery.js";
|
|
43
45
|
/** The model sources a test call has worked on: the setup is done for those, not for the others. */
|
|
@@ -85,6 +87,8 @@ export const studio = new Hono()
|
|
|
85
87
|
name: linked.name,
|
|
86
88
|
email: linked.email,
|
|
87
89
|
credits: overview?.tenant.balanceCredits ?? null,
|
|
90
|
+
/** What of the credits ends on a date, the nearest first. */
|
|
91
|
+
expiring: overview?.tenant.expiring ?? [],
|
|
88
92
|
url: env.local.accountUrl,
|
|
89
93
|
cloudUrl: env.local.cloudUrl,
|
|
90
94
|
signedIn: Boolean(overview),
|
|
@@ -101,8 +105,11 @@ export const studio = new Hono()
|
|
|
101
105
|
chatEngine: managed ? "models" : await chatEngine(await hasTextModel()),
|
|
102
106
|
aiReady: (await hasTextModel()) || (!managed && (await subscriptionClients()).length > 0),
|
|
103
107
|
mcpUrl: `${env.appUrl}/api/mcp`,
|
|
104
|
-
/**
|
|
105
|
-
|
|
108
|
+
/**
|
|
109
|
+
* One project: the studio shows no project switcher. `build: false`: nothing is made or
|
|
110
|
+
* changed here; the studio shows what the person's local install synced.
|
|
111
|
+
*/
|
|
112
|
+
limits: { projects: await projectLimit(), build: await mayBuild() },
|
|
106
113
|
});
|
|
107
114
|
})
|
|
108
115
|
// A runtime that runs alone says when a newer release is out and can run the update itself.
|
|
@@ -118,6 +125,7 @@ export const studio = new Hono()
|
|
|
118
125
|
// --- projects --------------------------------------------------------------
|
|
119
126
|
.get("/projects", async (c) => {
|
|
120
127
|
const projects = await listProjects(c.get("user").id);
|
|
128
|
+
const build = await mayBuild();
|
|
121
129
|
return c.json(projects.map((p) => ({
|
|
122
130
|
id: p.id,
|
|
123
131
|
name: p.name,
|
|
@@ -125,6 +133,10 @@ export const studio = new Hono()
|
|
|
125
133
|
facts: p.facts,
|
|
126
134
|
mcpServers: maskedServers(p),
|
|
127
135
|
wizardCount: p.wizardCount,
|
|
136
|
+
/** `local`: synced from a local install, where it is changed. */
|
|
137
|
+
origin: p.origin,
|
|
138
|
+
syncedAt: p.syncedAt?.toISOString() ?? null,
|
|
139
|
+
readOnly: p.origin === "local" || !build,
|
|
128
140
|
})));
|
|
129
141
|
})
|
|
130
142
|
.post("/projects", async (c) => {
|
|
@@ -232,6 +244,7 @@ export const studio = new Hono()
|
|
|
232
244
|
.post("/projects/:id/assist", async (c) => {
|
|
233
245
|
const user = c.get("user");
|
|
234
246
|
const project = await ownedProject(user.id, c.req.param("id"));
|
|
247
|
+
await requireWritable(project);
|
|
235
248
|
const body = z
|
|
236
249
|
.object({
|
|
237
250
|
message: z.string().min(1).max(8000),
|
|
@@ -363,6 +376,8 @@ export const studio = new Hono()
|
|
|
363
376
|
const project = await ownedProject(user.id, w.projectId);
|
|
364
377
|
return c.json({
|
|
365
378
|
...(await wizardState(w)),
|
|
379
|
+
/** Shown and run here, changed elsewhere: a local install's wizard, or nothing is built here. */
|
|
380
|
+
readOnly: !(await projectWritable(project)),
|
|
366
381
|
blank: messages.length === 0 && !w.starter,
|
|
367
382
|
messages: messages.map((m) => ({
|
|
368
383
|
id: m.id,
|
|
@@ -391,15 +406,28 @@ export const studio = new Hono()
|
|
|
391
406
|
})
|
|
392
407
|
.parse(await c.req.json());
|
|
393
408
|
await updateWizardSettings(c.get("user").id, c.req.param("id"), patch);
|
|
409
|
+
// The copy in the cloud of a linked account is shared the same way.
|
|
410
|
+
if (patch.shareEnabled !== undefined || patch.dailyRunLimit !== undefined) {
|
|
411
|
+
await pushSharing(c.get("user").id, c.req.param("id"));
|
|
412
|
+
}
|
|
394
413
|
return c.json({ ok: true });
|
|
414
|
+
})
|
|
415
|
+
// The wizard's ID for the mobile app, beside its link.
|
|
416
|
+
.get("/wizards/:id/code", async (c) => {
|
|
417
|
+
const w = await ownedWizard(c.get("user").id, c.req.param("id"));
|
|
418
|
+
return c.json({ code: await codeOf(w.shareToken) });
|
|
395
419
|
})
|
|
396
420
|
.post("/wizards/:id/rotate-link", async (c) => c.json(await rotateShareLink(c.get("user").id, c.req.param("id"))))
|
|
397
421
|
.post("/wizards/:id/duplicate", async (c) => c.json(await duplicateWizard(c.get("user").id, c.req.param("id"))))
|
|
398
422
|
.delete("/wizards/:id", async (c) => {
|
|
423
|
+
if (!managed) {
|
|
424
|
+
await removeFromCloud(c.get("user").id, c.req.param("id"));
|
|
425
|
+
}
|
|
399
426
|
await deleteWizard(c.get("user").id, c.req.param("id"));
|
|
400
427
|
return c.json({ ok: true });
|
|
401
428
|
})
|
|
402
|
-
|
|
429
|
+
// With an account linked the published version also goes to its cloud: `cloud` says how that went.
|
|
430
|
+
.post("/wizards/:id/publish", async (c) => c.json(await publishAndSync(c.get("user").id, c.req.param("id"))))
|
|
403
431
|
// What a run of the draft is expected to cost, per step and in total.
|
|
404
432
|
.get("/wizards/:id/estimate", async (c) => {
|
|
405
433
|
const w = await ownedWizard(c.get("user").id, c.req.param("id"));
|
|
@@ -413,13 +441,39 @@ export const studio = new Hono()
|
|
|
413
441
|
// A runtime that runs alone: the wizard's copy in the cloud of the linked account.
|
|
414
442
|
.get("/wizards/:id/cloud", async (c) => {
|
|
415
443
|
const w = await ownedWizard(c.get("user").id, c.req.param("id"));
|
|
416
|
-
|
|
444
|
+
const linked = await cloudLinked();
|
|
445
|
+
return c.json({ linked, ...(linked ? await cloudState(w.id) : { copy: null, error: null }) });
|
|
417
446
|
})
|
|
447
|
+
// Sends the published version again: after a try that failed, or a change of the project.
|
|
418
448
|
.post("/wizards/:id/cloud", async (c) => {
|
|
419
|
-
if (
|
|
449
|
+
if (!(await cloudLinked())) {
|
|
450
|
+
return c.notFound();
|
|
451
|
+
}
|
|
452
|
+
return c.json({
|
|
453
|
+
linked: true,
|
|
454
|
+
...(await syncToCloud(c.get("user").id, c.req.param("id"))),
|
|
455
|
+
});
|
|
456
|
+
})
|
|
457
|
+
// What the cloud would lack for the draft, before it is published.
|
|
458
|
+
.get("/wizards/:id/cloud/check", async (c) => {
|
|
459
|
+
if (!(await cloudLinked())) {
|
|
460
|
+
return c.notFound();
|
|
461
|
+
}
|
|
462
|
+
return c.json(await checkForCloud(c.get("user").id, c.req.param("id")));
|
|
463
|
+
})
|
|
464
|
+
// The projects of local installs the account's cloud holds.
|
|
465
|
+
.get("/cloud/spaces", async (c) => {
|
|
466
|
+
if (!(await cloudLinked())) {
|
|
467
|
+
return c.notFound();
|
|
468
|
+
}
|
|
469
|
+
return c.json({ spaces: await cloudSpaces() });
|
|
470
|
+
})
|
|
471
|
+
// Another install's project is in the way: it goes, and what is published here is sent.
|
|
472
|
+
.post("/cloud/replace", async (c) => {
|
|
473
|
+
if (!(await cloudLinked())) {
|
|
420
474
|
return c.notFound();
|
|
421
475
|
}
|
|
422
|
-
return c.json(await
|
|
476
|
+
return c.json(await replaceCloudSpaces(c.get("user").id));
|
|
423
477
|
})
|
|
424
478
|
.post("/wizards/:id/chat", async (c) => {
|
|
425
479
|
const user = c.get("user");
|
|
@@ -427,7 +481,7 @@ export const studio = new Hono()
|
|
|
427
481
|
const { message } = z
|
|
428
482
|
.object({ message: z.string().min(1).max(8000) })
|
|
429
483
|
.parse(await c.req.json());
|
|
430
|
-
await
|
|
484
|
+
await writableWizard(user.id, wizardId);
|
|
431
485
|
if (!(await canSpend())) {
|
|
432
486
|
throw new ServiceError("no_credits", "Dein Guthaben ist aufgebraucht.");
|
|
433
487
|
}
|
|
@@ -711,12 +765,26 @@ export const studio = new Hono()
|
|
|
711
765
|
await setChatEngine(engine);
|
|
712
766
|
return c.json({ ok: true });
|
|
713
767
|
})
|
|
714
|
-
|
|
768
|
+
// The address the system browser opens: the account's sign-in, or — for someone without an
|
|
769
|
+
// account — its sign-up page with the invitation code, which then goes on to the sign-in.
|
|
770
|
+
.post("/account/link", async (c) => {
|
|
771
|
+
if (managed) {
|
|
772
|
+
return c.notFound();
|
|
773
|
+
}
|
|
774
|
+
const input = z
|
|
775
|
+
.object({ signup: z.boolean().optional(), code: z.string().max(64).optional() })
|
|
776
|
+
.parse(await c.req.json().catch(() => ({})));
|
|
777
|
+
return c.json({ url: await startLink(input) });
|
|
778
|
+
})
|
|
715
779
|
.delete("/account", async (c) => {
|
|
716
780
|
if (managed) {
|
|
717
781
|
return c.notFound();
|
|
718
782
|
}
|
|
719
783
|
await unlink();
|
|
720
|
-
await
|
|
784
|
+
await forgetCloud();
|
|
785
|
+
// Models that ran on the account's credits have nothing to run on now; another source stays.
|
|
786
|
+
if (localModelSettings().source === "account") {
|
|
787
|
+
await saveLocalModels({ source: "own" });
|
|
788
|
+
}
|
|
721
789
|
return c.json({ ok: true });
|
|
722
790
|
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { and, eq } from "drizzle-orm";
|
|
3
|
+
import { db, schema } from "../db/client.js";
|
|
4
|
+
import { managed, tenantInfo } from "../manage.js";
|
|
5
|
+
import { currentTenant } from "../tenants/tenant.js";
|
|
6
|
+
import { ServiceError } from "./errors.js";
|
|
7
|
+
/**
|
|
8
|
+
* Who may change what on this runtime. A project a local install synced (`origin: "local"`) is
|
|
9
|
+
* changed only by that install's next sync: here it is shown and run. And a tenant the
|
|
10
|
+
* Manage-App gives no building (`limits.build: false`) makes and changes nothing on this
|
|
11
|
+
* runtime. Every write of a project or a wizard asks here, whichever way it came in: studio,
|
|
12
|
+
* MCP client or API.
|
|
13
|
+
*/
|
|
14
|
+
const sync = new AsyncLocalStorage();
|
|
15
|
+
/** Runs the writes of a sync: they are the one way a synced project changes. */
|
|
16
|
+
export function asSync(fn) {
|
|
17
|
+
return sync.run(true, fn);
|
|
18
|
+
}
|
|
19
|
+
const syncing = () => sync.getStore() === true;
|
|
20
|
+
/** Whether the tenant builds wizards on this runtime. A runtime that runs alone always does. */
|
|
21
|
+
export async function mayBuild() {
|
|
22
|
+
if (!managed) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
const info = await tenantInfo(currentTenant()).catch(() => null);
|
|
26
|
+
// An older Manage-App does not say: then it builds. One that cannot be reached: nothing changes.
|
|
27
|
+
return info ? info.limits.build !== false : false;
|
|
28
|
+
}
|
|
29
|
+
export const READ_ONLY_PROJECT = "Dieses Projekt kommt aus einer lokalen Installation und wird dort geändert.";
|
|
30
|
+
export const READ_ONLY_TENANT = "Hier wird nichts gebaut: Auf diesem Server laufen die Wizards, die deine lokale Installation veröffentlicht.";
|
|
31
|
+
/** Refuses when the tenant builds nothing here. */
|
|
32
|
+
export async function requireBuild() {
|
|
33
|
+
if (syncing() || (await mayBuild())) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
throw new ServiceError("read_only", READ_ONLY_TENANT);
|
|
37
|
+
}
|
|
38
|
+
/** Refuses a change of a synced project, and any change by a tenant that builds nothing here. */
|
|
39
|
+
export async function requireWritable(project) {
|
|
40
|
+
if (syncing()) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (project.origin === "local") {
|
|
44
|
+
throw new ServiceError("read_only", READ_ONLY_PROJECT);
|
|
45
|
+
}
|
|
46
|
+
await requireBuild();
|
|
47
|
+
}
|
|
48
|
+
export async function requireWritableProject(projectId) {
|
|
49
|
+
if (syncing()) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const project = await db.query.project.findFirst({
|
|
53
|
+
where: and(eq(schema.project.id, projectId), eq(schema.project.tenantId, currentTenant())),
|
|
54
|
+
columns: { origin: true },
|
|
55
|
+
});
|
|
56
|
+
// A project that is not there is the caller's "not found", not ours.
|
|
57
|
+
await requireWritable(project ?? { origin: null });
|
|
58
|
+
}
|
|
59
|
+
/** Whether the person may change this project's wizards and settings here. */
|
|
60
|
+
export async function projectWritable(project) {
|
|
61
|
+
return project.origin !== "local" && (await mayBuild());
|
|
62
|
+
}
|
|
@@ -7,7 +7,7 @@ import { managed } from "../manage.js";
|
|
|
7
7
|
import { hasTextModel } from "../models.js";
|
|
8
8
|
import { draftFiles, readFile } from "./files.js";
|
|
9
9
|
import { ownedProject } from "./projects.js";
|
|
10
|
-
import { addMessage, draftIssues, ownedWizard, wizardMessages } from "./wizards.js";
|
|
10
|
+
import { addMessage, draftIssues, ownedWizard, wizardMessages, writableWizard } from "./wizards.js";
|
|
11
11
|
/** The formats a model can look at; an SVG it reads as text. */
|
|
12
12
|
const VISIBLE = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
13
13
|
/** A picture larger than this would pass the model's limit once base64-encoded. */
|
|
@@ -31,7 +31,8 @@ async function attachedImages(userId, wizardId, message) {
|
|
|
31
31
|
}
|
|
32
32
|
/** One studio chat turn: the architect reads the draft, its workspace and the thread, and changes them with tools. */
|
|
33
33
|
export async function architectTurn(userId, wizardId, message, io) {
|
|
34
|
-
|
|
34
|
+
// Asked before the first model call: a wizard that is not changed here costs nothing to refuse.
|
|
35
|
+
const w = await writableWizard(userId, wizardId);
|
|
35
36
|
const history = await wizardMessages(w.id);
|
|
36
37
|
const project = await ownedProject(userId, w.projectId);
|
|
37
38
|
const images = await attachedImages(userId, w.id, message);
|
|
@@ -1,52 +1,254 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { and, eq, isNotNull, like } from "drizzle-orm";
|
|
2
|
+
import { accountToken, linkedAccount } from "../auth/account.js";
|
|
3
|
+
import { control, controlDb, db, schema } from "../db/client.js";
|
|
2
4
|
import { env } from "../env.js";
|
|
3
5
|
import { getBlob } from "../files/blobs.js";
|
|
4
|
-
import {
|
|
6
|
+
import { managed } from "../manage.js";
|
|
7
|
+
import { deleteSetting, readSetting, writeSetting } from "../settings.js";
|
|
8
|
+
import { currentTenant } from "../tenants/tenant.js";
|
|
9
|
+
import { mainLogoId } from "./brand.js";
|
|
5
10
|
import { ServiceError } from "./errors.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
11
|
+
import { projectFileContent, projectFiles } from "./project-files.js";
|
|
12
|
+
import { ownedProject } from "./projects.js";
|
|
13
|
+
import { ownedWizard, publishedVersion, publishWizard } from "./wizards.js";
|
|
8
14
|
const key = (wizardId) => `cloud:${wizardId}`;
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
15
|
+
const EMPTY = { copy: null, error: null };
|
|
16
|
+
/** Whether this runtime sends what it publishes: it runs alone and an account is linked. */
|
|
17
|
+
export async function cloudLinked() {
|
|
18
|
+
return !managed && Boolean(await linkedAccount());
|
|
12
19
|
}
|
|
13
|
-
/**
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
export async function publishToCloud(userId, wizardId) {
|
|
19
|
-
const w = await ownedWizard(userId, wizardId);
|
|
20
|
-
const { definition } = await requireClean(w);
|
|
20
|
+
/** Where a local wizard stands in the cloud. */
|
|
21
|
+
export async function cloudState(wizardId) {
|
|
22
|
+
return (await readSetting(key(wizardId))) ?? EMPTY;
|
|
23
|
+
}
|
|
24
|
+
async function call(method, path, body, timeoutMs = 30_000) {
|
|
21
25
|
const token = await accountToken();
|
|
22
26
|
if (!token) {
|
|
23
|
-
throw new ServiceError("refused", "
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
signal: AbortSignal.timeout(120_000),
|
|
27
|
+
throw new ServiceError("refused", "Das Konto ist nicht mehr angemeldet. Bitte neu anmelden.", {
|
|
28
|
+
reason: "signed_out",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const res = await fetch(`${env.local.cloudUrl}/api/v1${path}`, {
|
|
32
|
+
method,
|
|
33
|
+
headers: {
|
|
34
|
+
authorization: `Bearer ${token}`,
|
|
35
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
36
|
+
},
|
|
37
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
38
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
36
39
|
}).catch(() => null);
|
|
37
40
|
if (!res) {
|
|
38
|
-
throw new ServiceError("refused", "Die Cloud ist gerade nicht erreichbar."
|
|
41
|
+
throw new ServiceError("refused", "Die Cloud ist gerade nicht erreichbar.", {
|
|
42
|
+
reason: "unreachable",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
const parsed = (await res.json().catch(() => ({})));
|
|
46
|
+
return { ok: res.ok, status: res.status, body: parsed };
|
|
47
|
+
}
|
|
48
|
+
function refusal(answer, fallback) {
|
|
49
|
+
const { error, reason, ...rest } = answer.body;
|
|
50
|
+
const message = answer.status === 401
|
|
51
|
+
? "Das Konto ist nicht mehr angemeldet. Bitte neu anmelden."
|
|
52
|
+
: typeof error === "string" && error
|
|
53
|
+
? error
|
|
54
|
+
: fallback;
|
|
55
|
+
return new ServiceError("refused", message, {
|
|
56
|
+
reason: answer.status === 401 ? "signed_out" : typeof reason === "string" ? reason : "refused",
|
|
57
|
+
...(Array.isArray(rest.spaces) ? { spaces: rest.spaces } : {}),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** The project as the cloud keeps it: its name, what it says about itself, its logo. */
|
|
61
|
+
async function spaceOf(userId, projectId) {
|
|
62
|
+
const project = await ownedProject(userId, projectId);
|
|
63
|
+
const logoId = await mainLogoId(project.id);
|
|
64
|
+
const logo = logoId ? await projectFileContent(project.id, logoId).catch(() => null) : null;
|
|
65
|
+
return {
|
|
66
|
+
name: project.name,
|
|
67
|
+
brand: {
|
|
68
|
+
name: project.brand.name,
|
|
69
|
+
about: project.brand.about,
|
|
70
|
+
colors: project.brand.colors,
|
|
71
|
+
},
|
|
72
|
+
facts: project.facts,
|
|
73
|
+
logo: logo
|
|
74
|
+
? {
|
|
75
|
+
name: logo.row.name,
|
|
76
|
+
mime: logo.row.mime,
|
|
77
|
+
description: logo.row.description,
|
|
78
|
+
data: Buffer.from(logo.data).toString("base64"),
|
|
79
|
+
}
|
|
80
|
+
: null,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Sends a wizard's published version to the cloud: the same wizard again updates its copy
|
|
85
|
+
* there, whose link stays. What came back is kept; a try that failed is kept as such beside
|
|
86
|
+
* the copy from before.
|
|
87
|
+
*/
|
|
88
|
+
export async function syncToCloud(userId, wizardId) {
|
|
89
|
+
const w = await ownedWizard(userId, wizardId);
|
|
90
|
+
const version = await publishedVersion(w);
|
|
91
|
+
if (!version) {
|
|
92
|
+
throw new ServiceError("refused", "Bitte den Wizard zuerst veröffentlichen.");
|
|
93
|
+
}
|
|
94
|
+
const before = await cloudState(w.id);
|
|
95
|
+
try {
|
|
96
|
+
const files = await Promise.all(version.files.map(async (f) => ({
|
|
97
|
+
path: f.path,
|
|
98
|
+
mime: f.mime,
|
|
99
|
+
data: (await getBlob(f.hash)).toString("base64"),
|
|
100
|
+
})));
|
|
101
|
+
const answer = await call("PUT", `/spaces/${w.projectId}/wizards/${w.id}`, {
|
|
102
|
+
space: await spaceOf(userId, w.projectId),
|
|
103
|
+
version: version.version,
|
|
104
|
+
definition: version.definition,
|
|
105
|
+
files,
|
|
106
|
+
shareEnabled: w.shareEnabled,
|
|
107
|
+
dailyRunLimit: w.dailyRunLimit,
|
|
108
|
+
}, 120_000);
|
|
109
|
+
if (!answer.ok || !answer.body.shareUrl) {
|
|
110
|
+
throw refusal(answer, "Die Cloud hat den Wizard nicht angenommen.");
|
|
111
|
+
}
|
|
112
|
+
const state = {
|
|
113
|
+
copy: {
|
|
114
|
+
shareUrl: answer.body.shareUrl,
|
|
115
|
+
code: answer.body.code,
|
|
116
|
+
version: version.version,
|
|
117
|
+
publishedVersion: answer.body.publishedVersion,
|
|
118
|
+
runnable: answer.body.runnable,
|
|
119
|
+
problems: answer.body.problems ?? [],
|
|
120
|
+
syncedAt: new Date().toISOString(),
|
|
121
|
+
},
|
|
122
|
+
error: null,
|
|
123
|
+
};
|
|
124
|
+
await writeSetting(key(w.id), state);
|
|
125
|
+
return state;
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
const failed = {
|
|
129
|
+
copy: before.copy,
|
|
130
|
+
error: {
|
|
131
|
+
message: err instanceof ServiceError ? err.message : "Das Senden hat nicht geklappt.",
|
|
132
|
+
reason: err instanceof ServiceError ? err.data.reason : undefined,
|
|
133
|
+
at: new Date().toISOString(),
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
await writeSetting(key(w.id), failed);
|
|
137
|
+
if (!(err instanceof ServiceError)) {
|
|
138
|
+
console.error("[cloud] sync failed:", err);
|
|
139
|
+
}
|
|
140
|
+
return failed;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Publishes here and, with an account linked, sends the version on. The cloud never holds the
|
|
145
|
+
* publishing up or back: what it answered comes along as `cloud`.
|
|
146
|
+
*/
|
|
147
|
+
export async function publishAndSync(userId, wizardId) {
|
|
148
|
+
const published = await publishWizard(userId, wizardId);
|
|
149
|
+
const cloud = (await cloudLinked()) ? await syncToCloud(userId, wizardId) : null;
|
|
150
|
+
return { ...published, cloud };
|
|
151
|
+
}
|
|
152
|
+
/** How a wizard is shared changed here: the copy in the cloud follows. */
|
|
153
|
+
export async function pushSharing(userId, wizardId) {
|
|
154
|
+
if (!(await cloudLinked())) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const state = await cloudState(wizardId);
|
|
158
|
+
if (!state.copy) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const w = await ownedWizard(userId, wizardId);
|
|
162
|
+
await call("PATCH", `/spaces/${w.projectId}/wizards/${w.id}`, {
|
|
163
|
+
shareEnabled: w.shareEnabled,
|
|
164
|
+
dailyRunLimit: w.dailyRunLimit,
|
|
165
|
+
}).catch((err) => console.error("[cloud] sharing not sent:", err.message));
|
|
166
|
+
}
|
|
167
|
+
/** A wizard is about to be deleted here: its copy in the cloud goes too. */
|
|
168
|
+
export async function removeFromCloud(userId, wizardId) {
|
|
169
|
+
const state = await cloudState(wizardId);
|
|
170
|
+
if (!state.copy && !state.error) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const w = await ownedWizard(userId, wizardId).catch(() => null);
|
|
174
|
+
await deleteSetting(key(wizardId));
|
|
175
|
+
if (!w || !state.copy || !(await cloudLinked())) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
await call("DELETE", `/spaces/${w.projectId}/wizards/${w.id}`).catch((err) => console.error("[cloud] copy not removed:", err.message));
|
|
179
|
+
}
|
|
180
|
+
/** The account was unlinked: what this runtime kept of its cloud belongs to no account now. */
|
|
181
|
+
export async function forgetCloud() {
|
|
182
|
+
await controlDb.delete(control.setting).where(like(control.setting.key, "cloud:%"));
|
|
183
|
+
}
|
|
184
|
+
/** Sends every published wizard: once after an account was linked. */
|
|
185
|
+
export async function syncAllToCloud(userId) {
|
|
186
|
+
const wizards = await db
|
|
187
|
+
.select({ id: schema.wizard.id })
|
|
188
|
+
.from(schema.wizard)
|
|
189
|
+
.where(and(eq(schema.wizard.tenantId, currentTenant()), isNotNull(schema.wizard.publishedVersion)));
|
|
190
|
+
let sent = 0;
|
|
191
|
+
let failed = 0;
|
|
192
|
+
for (const { id } of wizards) {
|
|
193
|
+
const state = await syncToCloud(userId, id);
|
|
194
|
+
if (state.error) {
|
|
195
|
+
failed++;
|
|
196
|
+
// A refusal that holds for every wizard (no room, signed out) is not asked again.
|
|
197
|
+
if (state.error.reason && state.error.reason !== "refused") {
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
sent++;
|
|
203
|
+
}
|
|
39
204
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
205
|
+
return { sent, failed };
|
|
206
|
+
}
|
|
207
|
+
/** What the cloud would lack for the draft, asked before it is published. */
|
|
208
|
+
export async function checkForCloud(userId, wizardId) {
|
|
209
|
+
const w = await ownedWizard(userId, wizardId);
|
|
210
|
+
const files = await db
|
|
211
|
+
.select({ path: schema.wizardFile.path })
|
|
212
|
+
.from(schema.wizardFile)
|
|
213
|
+
.where(eq(schema.wizardFile.wizardId, w.id));
|
|
214
|
+
const answer = await call("POST", "/spaces/check", {
|
|
215
|
+
definition: w.draft,
|
|
216
|
+
files: files.map((f) => f.path),
|
|
217
|
+
});
|
|
218
|
+
if (!answer.ok) {
|
|
219
|
+
throw refusal(answer, "Die Cloud hat nicht geantwortet.");
|
|
43
220
|
}
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
publishedAt: new Date().toISOString(),
|
|
221
|
+
const documents = await projectFiles(w.projectId, "document");
|
|
222
|
+
return {
|
|
223
|
+
problems: answer.body.problems ?? [],
|
|
224
|
+
documents: documents.length > 0 && w.draft.steps.some((s) => s.type === "agent"),
|
|
49
225
|
};
|
|
50
|
-
|
|
51
|
-
|
|
226
|
+
}
|
|
227
|
+
/** The projects the account's cloud holds of local installs: this one's, or another machine's. */
|
|
228
|
+
export async function cloudSpaces() {
|
|
229
|
+
const answer = await call("GET", "/spaces");
|
|
230
|
+
if (!answer.ok) {
|
|
231
|
+
throw refusal(answer, "Die Cloud hat nicht geantwortet.");
|
|
232
|
+
}
|
|
233
|
+
return answer.body.spaces ?? [];
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Makes room for this install's project: the projects of other installs go from the cloud, with
|
|
237
|
+
* their links, then everything published here is sent. For the person who moved to a new
|
|
238
|
+
* machine or installed anew.
|
|
239
|
+
*/
|
|
240
|
+
export async function replaceCloudSpaces(userId) {
|
|
241
|
+
const mine = new Set((await db
|
|
242
|
+
.select({ id: schema.project.id })
|
|
243
|
+
.from(schema.project)
|
|
244
|
+
.where(eq(schema.project.tenantId, currentTenant()))).map((p) => p.id));
|
|
245
|
+
for (const space of await cloudSpaces()) {
|
|
246
|
+
if (!mine.has(space.id)) {
|
|
247
|
+
const answer = await call("DELETE", `/spaces/${space.id}`);
|
|
248
|
+
if (!answer.ok) {
|
|
249
|
+
throw refusal(answer, "Das Projekt in der Cloud ließ sich nicht entfernen.");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return syncAllToCloud(userId);
|
|
52
254
|
}
|
|
@@ -4,7 +4,7 @@ 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) {
|
|
@@ -77,7 +77,7 @@ export async function writeFile(userId, wizardId, rawPath, content, mime) {
|
|
|
77
77
|
return file;
|
|
78
78
|
}
|
|
79
79
|
export async function deleteFile(userId, wizardId, rawPath) {
|
|
80
|
-
const w = await
|
|
80
|
+
const w = await writableWizard(userId, wizardId);
|
|
81
81
|
const path = requirePath(rawPath);
|
|
82
82
|
const gone = await db
|
|
83
83
|
.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. */
|