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.
Files changed (78) hide show
  1. package/apps/runtime/dist/app.js +44 -0
  2. package/apps/runtime/dist/auth/account.js +16 -2
  3. package/apps/runtime/dist/auth/index.js +14 -1
  4. package/apps/runtime/dist/connectors/external.js +5 -0
  5. package/apps/runtime/dist/db/client.js +24 -3
  6. package/apps/runtime/dist/db/control-schema.js +12 -3
  7. package/apps/runtime/dist/db/migrations/control/0005_chilly_oracle.sql +1 -0
  8. package/apps/runtime/dist/db/migrations/control/0006_sleepy_mojo.sql +1 -0
  9. package/apps/runtime/dist/db/migrations/control/meta/0005_snapshot.json +358 -0
  10. package/apps/runtime/dist/db/migrations/control/meta/0006_snapshot.json +365 -0
  11. package/apps/runtime/dist/db/migrations/control/meta/_journal.json +14 -0
  12. package/apps/runtime/dist/db/migrations/tenant/0005_reflective_forgotten_one.sql +2 -0
  13. package/apps/runtime/dist/db/migrations/tenant/meta/0005_snapshot.json +1659 -0
  14. package/apps/runtime/dist/db/migrations/tenant/meta/_journal.json +7 -0
  15. package/apps/runtime/dist/db/schema.js +6 -0
  16. package/apps/runtime/dist/engine/asks.js +2 -0
  17. package/apps/runtime/dist/engine/push.js +58 -0
  18. package/apps/runtime/dist/engine/runner.js +6 -0
  19. package/apps/runtime/dist/env.js +13 -0
  20. package/apps/runtime/dist/index.js +6 -0
  21. package/apps/runtime/dist/limits.js +21 -0
  22. package/apps/runtime/dist/manage.js +16 -3
  23. package/apps/runtime/dist/mcp/tools.js +4 -2
  24. package/apps/runtime/dist/routes/api.js +96 -6
  25. package/apps/runtime/dist/routes/runs.js +68 -2
  26. package/apps/runtime/dist/routes/studio.js +79 -11
  27. package/apps/runtime/dist/services/access.js +62 -0
  28. package/apps/runtime/dist/services/architect.js +3 -2
  29. package/apps/runtime/dist/services/cloud.js +298 -39
  30. package/apps/runtime/dist/services/errors.js +1 -0
  31. package/apps/runtime/dist/services/files.js +53 -4
  32. package/apps/runtime/dist/services/project-files.js +6 -0
  33. package/apps/runtime/dist/services/projects.js +38 -14
  34. package/apps/runtime/dist/services/spaces.js +400 -0
  35. package/apps/runtime/dist/services/wizards.js +19 -8
  36. package/apps/runtime/dist/tenants/control.js +47 -0
  37. package/apps/runtime/dist/update.js +3 -3
  38. package/apps/web/dist/assets/{ChangelogDialog-4HCt88rf.js → ChangelogDialog-lGa0JKY1.js} +1 -1
  39. package/apps/web/dist/assets/ChatPanel-Dk_45uca.js +1 -0
  40. package/apps/web/dist/assets/{CreditsDialog-D3fZRqXH.js → CreditsDialog-BPMWo6tp.js} +1 -1
  41. package/apps/web/dist/assets/EditorPage-Dt4MCcrU.js +2 -0
  42. package/apps/web/dist/assets/{FlowPreview-CjUK5046.js → FlowPreview-gwtUv8-X.js} +1 -1
  43. package/apps/web/dist/assets/HomePage-Bi9FK3_1.js +1 -0
  44. package/apps/web/dist/assets/ImportWizard-D6y4bKlF.js +1 -0
  45. package/apps/web/dist/assets/{LocalRuntime-DupxjXff.js → LocalRuntime-BjzPSsHr.js} +10 -10
  46. package/apps/web/dist/assets/NewWizardPage-2HbCaPSt.js +2 -0
  47. package/apps/web/dist/assets/PublicRunner-Dac_e6wg.js +1 -0
  48. package/apps/web/dist/assets/RunnerView-BC6crf1p.js +2 -0
  49. package/apps/web/dist/assets/SettingsPage-BfXxa4SU.js +2 -0
  50. package/apps/web/dist/assets/{SetupPage-xiqJMR0J.js → SetupPage-CYKVspBJ.js} +1 -1
  51. package/apps/web/dist/assets/{SharePage-C8B9BUSU.js → SharePage-DFIHsNS-.js} +1 -1
  52. package/apps/web/dist/assets/{api-KMi35npD.js → api-BTfKh3La.js} +1 -1
  53. package/apps/web/dist/assets/changelog-CLmll9By.js +1 -0
  54. package/apps/web/dist/assets/index-CIwJmOjo.css +1 -0
  55. package/apps/web/dist/assets/{index-2Ktgo6f1.js → index-DOpyzQiU.js} +3 -3
  56. package/apps/web/dist/assets/{meta-4R1Lnr25.js → meta-BbVhbYf6.js} +1 -1
  57. package/apps/web/dist/assets/oss-credits-Hu4LieLA.js +1 -0
  58. package/apps/web/dist/assets/outputs-n_yhxmMk.js +69 -0
  59. package/apps/web/dist/assets/{session-B6-6VomQ.js → session-D_Zggq_t.js} +1 -1
  60. package/apps/web/dist/assets/type-BMJpS6_D.js +1 -0
  61. package/apps/web/dist/assets/ui-C3bTWmdM.js +1 -0
  62. package/apps/web/dist/index.html +5 -5
  63. package/apps/web/dist/mcp/flow.html +54 -54
  64. package/package.json +1 -1
  65. package/apps/web/dist/assets/ChatPanel-B91fTrzg.js +0 -1
  66. package/apps/web/dist/assets/EditorPage-ZZjb9mGV.js +0 -2
  67. package/apps/web/dist/assets/HomePage-Cn5TCZk7.js +0 -1
  68. package/apps/web/dist/assets/ImportWizard-DiV62Uno.js +0 -1
  69. package/apps/web/dist/assets/NewWizardPage-Bz3PIQGp.js +0 -2
  70. package/apps/web/dist/assets/PublicRunner-Axs1zwqe.js +0 -1
  71. package/apps/web/dist/assets/RunnerView-Bhv6RNME.js +0 -2
  72. package/apps/web/dist/assets/SettingsPage-in_RmI8i.js +0 -2
  73. package/apps/web/dist/assets/changelog-C2LDEzLQ.js +0 -1
  74. package/apps/web/dist/assets/index-B9YVjvVY.css +0 -1
  75. package/apps/web/dist/assets/oss-credits-roB62QJu.js +0 -1
  76. package/apps/web/dist/assets/outputs-Dhqtf-SA.js +0 -69
  77. package/apps/web/dist/assets/type-Bj9H3eOK.js +0 -1
  78. 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 { cloudCopy, publishToCloud } from "../services/cloud.js";
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, publishWizard, rotateShareLink, updateWizardSettings, wizardMessages, wizardState, writeDraft, } from "../services/wizards.js";
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
- /** One project: the studio shows no project switcher. */
105
- limits: { projects: await projectLimit() },
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
- .post("/wizards/:id/publish", async (c) => c.json(await publishWizard(c.get("user").id, c.req.param("id"))))
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
- return c.json({ copy: managed ? null : await cloudCopy(w.id) });
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 (managed) {
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 publishToCloud(c.get("user").id, c.req.param("id")));
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 ownedWizard(user.id, wizardId);
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
- .post("/account/link", async (c) => (managed ? c.notFound() : c.json({ url: await startLink() })))
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 saveLocalModels({ source: "own" });
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
- const w = await ownedWizard(userId, wizardId);
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,311 @@
1
- import { accountToken } from "../auth/account.js";
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 { readSetting, writeSetting } from "../settings.js";
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 { draftFiles } from "./files.js";
7
- import { ownedWizard, requireClean } from "./wizards.js";
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
- /** Where a local wizard was published to the cloud, if it was. */
10
- export function cloudCopy(wizardId) {
11
- return readSetting(key(wizardId));
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
- * Publishes a local wizard to the cloud runtime of the linked account: definition and workspace
15
- * go through the cloud's import API, which validates them like any other write. Publishing
16
- * again updates the same cloud wizard, so its link stays.
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", "Bitte zuerst in den Einstellungen ein Konto anmelden.");
24
- }
25
- const files = await Promise.all((await draftFiles(w.id)).map(async (f) => ({
26
- path: f.path,
27
- mime: f.mime,
28
- data: (await getBlob(f.hash)).toString("base64"),
29
- })));
30
- const before = await cloudCopy(w.id);
31
- const res = await fetch(`${env.local.cloudUrl}/api/v1/wizards/import`, {
32
- method: "POST",
33
- headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
34
- body: JSON.stringify({ wizardId: before?.wizardId, definition, files, publish: true }),
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
+ status: answer.status,
58
+ ...(Array.isArray(rest.spaces) ? { spaces: rest.spaces } : {}),
59
+ });
60
+ }
61
+ /** A try is made again by itself when the cloud, not what was sent, was the trouble. */
62
+ function worthAnotherTry(err) {
63
+ if (!(err instanceof ServiceError)) {
64
+ return true;
65
+ }
66
+ const status = typeof err.data.status === "number" ? err.data.status : 0;
67
+ return err.data.reason === "unreachable" || status >= 500 || status === 429;
68
+ }
69
+ /** A minute, then twice as long each time, up to an hour. */
70
+ function nextTry(tries, from) {
71
+ const minutes = Math.min(60, 2 ** (tries - 1));
72
+ return new Date(from.getTime() + minutes * 60_000).toISOString();
73
+ }
74
+ /** The project as the cloud keeps it: its name, what it says about itself, its logo. */
75
+ async function spaceOf(userId, projectId) {
76
+ const project = await ownedProject(userId, projectId);
77
+ const logoId = await mainLogoId(project.id);
78
+ const logo = logoId ? await projectFileContent(project.id, logoId).catch(() => null) : null;
79
+ return {
80
+ name: project.name,
81
+ brand: {
82
+ name: project.brand.name,
83
+ about: project.brand.about,
84
+ colors: project.brand.colors,
85
+ },
86
+ facts: project.facts,
87
+ logo: logo
88
+ ? {
89
+ name: logo.row.name,
90
+ mime: logo.row.mime,
91
+ description: logo.row.description,
92
+ data: Buffer.from(logo.data).toString("base64"),
93
+ }
94
+ : null,
95
+ };
96
+ }
97
+ /**
98
+ * Sends a wizard's published version to the cloud: the same wizard again updates its copy
99
+ * there, whose link stays. What came back is kept; a try that failed is kept as such beside
100
+ * the copy from before.
101
+ */
102
+ export async function syncToCloud(userId, wizardId) {
103
+ const w = await ownedWizard(userId, wizardId);
104
+ const version = await publishedVersion(w);
105
+ if (!version) {
106
+ throw new ServiceError("refused", "Bitte den Wizard zuerst veröffentlichen.");
107
+ }
108
+ const before = await cloudState(w.id);
109
+ try {
110
+ const files = await Promise.all(version.files.map(async (f) => ({
111
+ path: f.path,
112
+ mime: f.mime,
113
+ data: (await getBlob(f.hash)).toString("base64"),
114
+ })));
115
+ const answer = await call("PUT", `/spaces/${w.projectId}/wizards/${w.id}`, {
116
+ space: await spaceOf(userId, w.projectId),
117
+ version: version.version,
118
+ definition: version.definition,
119
+ files,
120
+ shareEnabled: w.shareEnabled,
121
+ dailyRunLimit: w.dailyRunLimit,
122
+ }, 120_000);
123
+ if (!answer.ok || !answer.body.shareUrl) {
124
+ throw refusal(answer, "Die Cloud hat den Wizard nicht angenommen.");
125
+ }
126
+ const state = {
127
+ copy: {
128
+ shareUrl: answer.body.shareUrl,
129
+ code: answer.body.code,
130
+ version: version.version,
131
+ publishedVersion: answer.body.publishedVersion,
132
+ runnable: answer.body.runnable,
133
+ problems: answer.body.problems ?? [],
134
+ syncedAt: new Date().toISOString(),
135
+ },
136
+ error: null,
137
+ };
138
+ await writeSetting(key(w.id), state);
139
+ return state;
140
+ }
141
+ catch (err) {
142
+ const now = new Date();
143
+ const tries = (before.error?.tries ?? 0) + 1;
144
+ const failed = {
145
+ copy: before.copy,
146
+ error: {
147
+ message: err instanceof ServiceError ? err.message : "Das Senden hat nicht geklappt.",
148
+ reason: err instanceof ServiceError ? err.data.reason : undefined,
149
+ at: now.toISOString(),
150
+ tries,
151
+ again: worthAnotherTry(err) ? nextTry(tries, now) : null,
152
+ },
153
+ };
154
+ await writeSetting(key(w.id), failed);
155
+ if (!(err instanceof ServiceError)) {
156
+ console.error("[cloud] sync failed:", err);
157
+ }
158
+ return failed;
159
+ }
160
+ }
161
+ /**
162
+ * Publishes here and, with an account linked, sends the version on. The cloud never holds the
163
+ * publishing up or back: what it answered comes along as `cloud`.
164
+ */
165
+ export async function publishAndSync(userId, wizardId) {
166
+ const published = await publishWizard(userId, wizardId);
167
+ const cloud = (await cloudLinked()) ? await syncToCloud(userId, wizardId) : null;
168
+ return { ...published, cloud };
169
+ }
170
+ /** How a wizard is shared changed here: the copy in the cloud follows. */
171
+ export async function pushSharing(userId, wizardId) {
172
+ if (!(await cloudLinked())) {
173
+ return;
174
+ }
175
+ const state = await cloudState(wizardId);
176
+ if (!state.copy) {
177
+ return;
178
+ }
179
+ const w = await ownedWizard(userId, wizardId);
180
+ await call("PATCH", `/spaces/${w.projectId}/wizards/${w.id}`, {
181
+ shareEnabled: w.shareEnabled,
182
+ dailyRunLimit: w.dailyRunLimit,
183
+ }).catch((err) => console.error("[cloud] sharing not sent:", err.message));
184
+ }
185
+ /** A wizard is about to be deleted here: its copy in the cloud goes too. */
186
+ export async function removeFromCloud(userId, wizardId) {
187
+ const state = await cloudState(wizardId);
188
+ if (!state.copy && !state.error) {
189
+ return;
190
+ }
191
+ const w = await ownedWizard(userId, wizardId).catch(() => null);
192
+ await deleteSetting(key(wizardId));
193
+ if (!w || !state.copy || !(await cloudLinked())) {
194
+ return;
195
+ }
196
+ await call("DELETE", `/spaces/${w.projectId}/wizards/${w.id}`).catch((err) => console.error("[cloud] copy not removed:", err.message));
197
+ }
198
+ /**
199
+ * Sends again what did not arrive and is due for another try. Called every minute by the
200
+ * runtime that runs alone; a wizard that is gone meanwhile is forgotten.
201
+ */
202
+ export async function retryFailedSends(userId) {
203
+ if (!(await cloudLinked())) {
204
+ return;
205
+ }
206
+ const now = new Date().toISOString();
207
+ const rows = await controlDb
208
+ .select({ key: control.setting.key, value: control.setting.value })
209
+ .from(control.setting)
210
+ .where(like(control.setting.key, "cloud:%"));
211
+ for (const row of rows) {
212
+ const state = row.value;
213
+ if (!state.error?.again || state.error.again > now) {
214
+ continue;
215
+ }
216
+ const wizardId = row.key.slice("cloud:".length);
217
+ const w = await ownedWizard(userId, wizardId).catch(() => null);
218
+ if (!w?.publishedVersion) {
219
+ await deleteSetting(row.key);
220
+ continue;
221
+ }
222
+ const sent = await syncToCloud(userId, wizardId);
223
+ // Something the person has to see to (signed out, no room) holds for every wizard.
224
+ if (sent.error && !sent.error.again) {
225
+ break;
226
+ }
39
227
  }
40
- const body = (await res.json().catch(() => ({})));
41
- if (!res.ok || !body.wizardId || !body.shareUrl) {
42
- throw new ServiceError(body.issues?.length ? "has_issues" : "refused", body.error ?? "Veröffentlichen in der Cloud hat nicht geklappt.", body.issues?.length ? { issues: body.issues } : {});
228
+ }
229
+ /** The account was unlinked: what this runtime kept of its cloud belongs to no account now. */
230
+ export async function forgetCloud() {
231
+ await controlDb.delete(control.setting).where(like(control.setting.key, "cloud:%"));
232
+ }
233
+ /** Sends every published wizard: once after an account was linked. */
234
+ export async function syncAllToCloud(userId) {
235
+ const wizards = await db
236
+ .select({ id: schema.wizard.id })
237
+ .from(schema.wizard)
238
+ .where(and(eq(schema.wizard.tenantId, currentTenant()), isNotNull(schema.wizard.publishedVersion)));
239
+ let sent = 0;
240
+ let failed = 0;
241
+ for (const [i, { id }] of wizards.entries()) {
242
+ const state = await syncToCloud(userId, id);
243
+ if (state.error) {
244
+ failed++;
245
+ // A refusal that holds for every wizard (no room, signed out) is not asked again; a cloud
246
+ // out of reach neither, but the rest is then tried again later, with this one.
247
+ if (state.error.reason && state.error.reason !== "refused") {
248
+ for (const rest of wizards.slice(i + 1)) {
249
+ failed++;
250
+ await writeSetting(key(rest.id), {
251
+ copy: (await cloudState(rest.id)).copy,
252
+ error: state.error,
253
+ });
254
+ }
255
+ break;
256
+ }
257
+ }
258
+ else {
259
+ sent++;
260
+ }
261
+ }
262
+ return { sent, failed };
263
+ }
264
+ /** What the cloud would lack for the draft, asked before it is published. */
265
+ export async function checkForCloud(userId, wizardId) {
266
+ const w = await ownedWizard(userId, wizardId);
267
+ const files = await db
268
+ .select({ path: schema.wizardFile.path })
269
+ .from(schema.wizardFile)
270
+ .where(eq(schema.wizardFile.wizardId, w.id));
271
+ const answer = await call("POST", "/spaces/check", {
272
+ definition: w.draft,
273
+ files: files.map((f) => f.path),
274
+ });
275
+ if (!answer.ok) {
276
+ throw refusal(answer, "Die Cloud hat nicht geantwortet.");
43
277
  }
44
- const copy = {
45
- wizardId: body.wizardId,
46
- shareUrl: body.shareUrl,
47
- version: body.version ?? 1,
48
- publishedAt: new Date().toISOString(),
278
+ const documents = await projectFiles(w.projectId, "document");
279
+ return {
280
+ problems: answer.body.problems ?? [],
281
+ documents: documents.length > 0 && w.draft.steps.some((s) => s.type === "agent"),
49
282
  };
50
- await writeSetting(key(w.id), copy);
51
- return copy;
283
+ }
284
+ /** The projects the account's cloud holds of local installs: this one's, or another machine's. */
285
+ export async function cloudSpaces() {
286
+ const answer = await call("GET", "/spaces");
287
+ if (!answer.ok) {
288
+ throw refusal(answer, "Die Cloud hat nicht geantwortet.");
289
+ }
290
+ return answer.body.spaces ?? [];
291
+ }
292
+ /**
293
+ * Makes room for this install's project: the projects of other installs go from the cloud, with
294
+ * their links, then everything published here is sent. For the person who moved to a new
295
+ * machine or installed anew.
296
+ */
297
+ export async function replaceCloudSpaces(userId) {
298
+ const mine = new Set((await db
299
+ .select({ id: schema.project.id })
300
+ .from(schema.project)
301
+ .where(eq(schema.project.tenantId, currentTenant()))).map((p) => p.id));
302
+ for (const space of await cloudSpaces()) {
303
+ if (!mine.has(space.id)) {
304
+ const answer = await call("DELETE", `/spaces/${space.id}`);
305
+ if (!answer.ok) {
306
+ throw refusal(answer, "Das Projekt in der Cloud ließ sich nicht entfernen.");
307
+ }
308
+ }
309
+ }
310
+ return syncAllToCloud(userId);
52
311
  }
@@ -5,6 +5,7 @@ const STATUS = {
5
5
  revision_conflict: 409,
6
6
  no_credits: 402,
7
7
  refused: 400,
8
+ read_only: 403,
8
9
  };
9
10
  export class ServiceError extends Error {
10
11
  code;