wizards 0.2.3 → 0.2.5

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 (48) hide show
  1. package/apps/runtime/dist/db/client.js +24 -3
  2. package/apps/runtime/dist/index.js +6 -0
  3. package/apps/runtime/dist/routes/api.js +13 -2
  4. package/apps/runtime/dist/routes/studio.js +11 -3
  5. package/apps/runtime/dist/services/cloud.js +84 -3
  6. package/apps/runtime/dist/services/files.js +50 -1
  7. package/apps/runtime/dist/services/profile.js +18 -3
  8. package/apps/runtime/dist/services/spaces.js +91 -85
  9. package/apps/runtime/dist/update.js +3 -3
  10. package/apps/web/dist/assets/Account-BKribBzW.js +1 -0
  11. package/apps/web/dist/assets/{ChangelogDialog-CbZBUMRu.js → ChangelogDialog-CYQC4l57.js} +1 -1
  12. package/apps/web/dist/assets/{ChatPanel-zurtfj6A.js → ChatPanel-Cnd_dfF2.js} +1 -1
  13. package/apps/web/dist/assets/{CreditsDialog-BewZHZTR.js → CreditsDialog-7nLXWFvP.js} +1 -1
  14. package/apps/web/dist/assets/EditorPage-BjFAF_4T.js +2 -0
  15. package/apps/web/dist/assets/{FlowPreview-DHrhPps0.js → FlowPreview-BT4i-qIF.js} +1 -1
  16. package/apps/web/dist/assets/HomePage-F04N9Y1Q.js +1 -0
  17. package/apps/web/dist/assets/{ImportWizard-CKjVJMaG.js → ImportWizard-Cg0l6q88.js} +1 -1
  18. package/apps/web/dist/assets/{LocalRuntime-Brkvbzzg.js → LocalRuntime-BoVnl1ga.js} +2 -2
  19. package/apps/web/dist/assets/NewWizardPage-DumPEWtE.js +2 -0
  20. package/apps/web/dist/assets/{PublicRunner-Bn1g6HG9.js → PublicRunner-CllgpTeL.js} +1 -1
  21. package/apps/web/dist/assets/{RunnerView-CO1Nk3jn.js → RunnerView-adkYnOSi.js} +1 -1
  22. package/apps/web/dist/assets/SettingsPage-7EG_dMSP.js +2 -0
  23. package/apps/web/dist/assets/{SetupPage-BvLwZH9i.js → SetupPage-BzZMf7Oz.js} +1 -1
  24. package/apps/web/dist/assets/{SharePage-CDESJ11B.js → SharePage-CONXJnOU.js} +1 -1
  25. package/apps/web/dist/assets/{api-9sHuNTkp.js → api-BC3K8Na8.js} +1 -1
  26. package/apps/web/dist/assets/changelog-C0GmxRP2.js +1 -0
  27. package/apps/web/dist/assets/cloud-off-D--dZLDw.js +1 -0
  28. package/apps/web/dist/assets/index-CJ3iuK8-.js +10 -0
  29. package/apps/web/dist/assets/index-mpjxO44v.css +1 -0
  30. package/apps/web/dist/assets/{meta-B4mZqEso.js → meta-BeYxDfVN.js} +1 -1
  31. package/apps/web/dist/assets/{outputs-boPx5ngv.js → outputs-DLmQMIUm.js} +1 -1
  32. package/apps/web/dist/assets/{session-CJGuo1Tv.js → session-_w8H5O_d.js} +1 -1
  33. package/apps/web/dist/assets/type-DnfgUhpM.js +1 -0
  34. package/apps/web/dist/assets/ui-Cj3EzOCA.js +1 -0
  35. package/apps/web/dist/assets/upload-DNRENWzQ.js +1 -0
  36. package/apps/web/dist/index.html +6 -5
  37. package/apps/web/dist/mcp/flow.html +2 -2
  38. package/package.json +1 -1
  39. package/apps/web/dist/assets/EditorPage-B7ZOoW5h.js +0 -2
  40. package/apps/web/dist/assets/HomePage-CBvWxXsq.js +0 -1
  41. package/apps/web/dist/assets/NewWizardPage-B3XePMlp.js +0 -2
  42. package/apps/web/dist/assets/SettingsPage-BIY4kHZl.js +0 -2
  43. package/apps/web/dist/assets/changelog-Cto0j5-V.js +0 -1
  44. package/apps/web/dist/assets/index-CIwJmOjo.css +0 -1
  45. package/apps/web/dist/assets/index-VsRgvimv.js +0 -10
  46. package/apps/web/dist/assets/type-BMJpS6_D.js +0 -1
  47. package/apps/web/dist/assets/ui-iTYHqwvY.js +0 -1
  48. package/apps/web/dist/assets/upload--J5bZhEM.js +0 -1
@@ -1,3 +1,4 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
1
2
  import { mkdirSync } from "node:fs";
2
3
  import { dirname, join } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
@@ -117,14 +118,34 @@ export async function withTenant(id, fn) {
117
118
  await openTenantDb(id);
118
119
  return inTenant(id, fn);
119
120
  }
121
+ /** The transaction the current context writes in, while `inTransaction()` runs. */
122
+ const transactions = new AsyncLocalStorage();
120
123
  /**
121
- * The current tenant's database, resolved from the context `inTenant()` set. Code that reads or
122
- * writes wizards, runs or files never names a tenant itself.
124
+ * Runs `fn` as one transaction of the current tenant's database: inside it, `db` is the
125
+ * transaction, so every write of the services called there stands or falls with the others.
126
+ * Already inside one, `fn` just joins it. Only this database takes part: the object store and
127
+ * the control database are written as before, so do those first or after.
128
+ */
129
+ export async function inTransaction(fn) {
130
+ if (transactions.getStore()) {
131
+ return fn();
132
+ }
133
+ const id = currentTenant();
134
+ const target = ready.get(id);
135
+ if (!target) {
136
+ throw new Error(`Tenant database "${id}" is not open: enter it with withTenant().`);
137
+ }
138
+ return target.transaction((tx) => transactions.run(tx, fn));
139
+ }
140
+ /**
141
+ * The current tenant's database, resolved from the context `inTenant()` set — or the
142
+ * transaction `inTransaction()` opened there. Code that reads or writes wizards, runs or files
143
+ * never names a tenant itself.
123
144
  */
124
145
  export const db = new Proxy({}, {
125
146
  get(_target, prop) {
126
147
  const id = currentTenant();
127
- const target = ready.get(id);
148
+ const target = transactions.getStore() ?? ready.get(id);
128
149
  if (!target) {
129
150
  throw new Error(`Tenant database "${id}" is not open: enter it with withTenant().`);
130
151
  }
@@ -68,6 +68,12 @@ async function purge() {
68
68
  }
69
69
  void purge().catch((err) => console.error("[retention]", err));
70
70
  setInterval(() => void purge().catch((err) => console.error("[retention]", err)), 3600_000).unref();
71
+ // A runtime that runs alone sends again what did not arrive in its account's cloud.
72
+ if (!managed) {
73
+ const { retryFailedSends } = await import("./services/cloud.js");
74
+ const retry = () => withTenant(LOCAL_TENANT, () => retryFailedSends("local")).catch((err) => console.error("[cloud] retry failed:", err));
75
+ setInterval(() => void retry(), 60_000).unref();
76
+ }
71
77
  async function shutdown(code = 0) {
72
78
  clearRunning(env.dataDir);
73
79
  server.close();
@@ -6,7 +6,7 @@ import { allowed } from "../limits.js";
6
6
  import { authenticate, principalOf } from "../mcp/auth.js";
7
7
  import { ServiceError } from "../services/errors.js";
8
8
  import { deleteFile, draftFiles, writeFile } from "../services/files.js";
9
- import { checkOnServer, checkSchema, listSyncedSpaces, patchSyncedWizard, removeSyncedSpace, removeSyncedWizard, syncSettingsSchema, syncWizard, syncWizardSchema, } from "../services/spaces.js";
9
+ import { checkOnServer, checkSchema, listSyncedSpaces, patchSyncedWizard, removeSyncedSpace, removeSyncedWizard, rotateSyncedLink, syncSettingsSchema, syncWizard, syncWizardSchema, } from "../services/spaces.js";
10
10
  import { createWizard, ownedWizard, publishWizard, writeDraft } from "../services/wizards.js";
11
11
  import { codeOf, tenantStatus } from "../tenants/control.js";
12
12
  const importSchema = z.object({
@@ -82,7 +82,7 @@ export const apiRoutes = new Hono()
82
82
  return limited;
83
83
  }
84
84
  const body = syncWizardSchema.parse(await c.req.json());
85
- return withTenant(who.tenantId, async () => c.json(await syncWizard(who.userId, c.req.param("spaceId"), c.req.param("wizardId"), body)));
85
+ return withTenant(who.tenantId, async () => c.json(await syncWizard(c.req.param("spaceId"), c.req.param("wizardId"), body)));
86
86
  })
87
87
  .patch("/spaces/:spaceId/wizards/:wizardId", async (c) => {
88
88
  const who = await caller(c, ["wizards:publish"]);
@@ -95,6 +95,17 @@ export const apiRoutes = new Hono()
95
95
  }
96
96
  const body = syncSettingsSchema.parse(await c.req.json());
97
97
  return withTenant(who.tenantId, async () => c.json(await patchSyncedWizard(c.req.param("spaceId"), c.req.param("wizardId"), body)));
98
+ })
99
+ .post("/spaces/:spaceId/wizards/:wizardId/rotate-link", async (c) => {
100
+ const who = await caller(c, ["wizards:publish"]);
101
+ if (who instanceof Response) {
102
+ return who;
103
+ }
104
+ const limited = withinSyncLimit(c, who);
105
+ if (limited) {
106
+ return limited;
107
+ }
108
+ return withTenant(who.tenantId, async () => c.json(await rotateSyncedLink(who.userId, c.req.param("spaceId"), c.req.param("wizardId"))));
98
109
  })
99
110
  .delete("/spaces/:spaceId/wizards/:wizardId", async (c) => {
100
111
  const who = await caller(c, ["wizards:write"]);
@@ -27,7 +27,7 @@ import { embeddingModel, hasTextModel, LOCAL_KEYS, localModelSettings, ModelUnav
27
27
  import { HTML_RESPONSE_CSP } from "../render/guard.js";
28
28
  import { mayBuild, projectWritable, requireWritable } from "../services/access.js";
29
29
  import { architectTurn } from "../services/architect.js";
30
- import { checkForCloud, cloudLinked, cloudSpaces, cloudState, forgetCloud, publishAndSync, pushSharing, removeFromCloud, replaceCloudSpaces, syncToCloud, } from "../services/cloud.js";
30
+ import { checkForCloud, cloudLinked, cloudSpaces, cloudState, forgetCloud, publishAndSync, pushSharing, removeFromCloud, replaceCloudSpaces, rotateInCloud, syncToCloud, } from "../services/cloud.js";
31
31
  import { ServiceError } from "../services/errors.js";
32
32
  import { deleteFile, listFiles, readFile, writeFile } from "../services/files.js";
33
33
  import { exportWizard, importWizard, PACKAGE_EXTENSION, PACKAGE_MAX_BYTES, PACKAGE_MIME, } from "../services/package.js";
@@ -340,7 +340,10 @@ export const studio = new Hono()
340
340
  .get("/projects/:id/wizards", async (c) => {
341
341
  const user = c.get("user");
342
342
  await ownedProject(user.id, c.req.param("id"));
343
- return c.json(await listWizards(user.id, c.req.param("id")));
343
+ const wizards = await listWizards(user.id, c.req.param("id"));
344
+ // A local install with an account: where each wizard stands in its cloud, for the cards.
345
+ const linked = await cloudLinked();
346
+ return c.json(await Promise.all(wizards.map(async (w) => ({ ...w, cloud: linked ? await cloudState(w.id) : null }))));
344
347
  })
345
348
  .post("/wizards", async (c) => {
346
349
  const body = z
@@ -417,7 +420,12 @@ export const studio = new Hono()
417
420
  const w = await ownedWizard(c.get("user").id, c.req.param("id"));
418
421
  return c.json({ code: await codeOf(w.shareToken) });
419
422
  })
420
- .post("/wizards/:id/rotate-link", async (c) => c.json(await rotateShareLink(c.get("user").id, c.req.param("id"))))
423
+ // A new link here, and for the copy in the cloud of a linked account: the old ones stop.
424
+ .post("/wizards/:id/rotate-link", async (c) => {
425
+ const rotated = await rotateShareLink(c.get("user").id, c.req.param("id"));
426
+ const cloud = managed ? null : await rotateInCloud(c.get("user").id, c.req.param("id"));
427
+ return c.json({ ...rotated, cloud });
428
+ })
421
429
  .post("/wizards/:id/duplicate", async (c) => c.json(await duplicateWizard(c.get("user").id, c.req.param("id"))))
422
430
  .delete("/wizards/:id", async (c) => {
423
431
  if (!managed) {
@@ -54,9 +54,23 @@ function refusal(answer, fallback) {
54
54
  : fallback;
55
55
  return new ServiceError("refused", message, {
56
56
  reason: answer.status === 401 ? "signed_out" : typeof reason === "string" ? reason : "refused",
57
+ status: answer.status,
57
58
  ...(Array.isArray(rest.spaces) ? { spaces: rest.spaces } : {}),
58
59
  });
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
+ }
60
74
  /** The project as the cloud keeps it: its name, what it says about itself, its logo. */
61
75
  async function spaceOf(userId, projectId) {
62
76
  const project = await ownedProject(userId, projectId);
@@ -125,12 +139,16 @@ export async function syncToCloud(userId, wizardId) {
125
139
  return state;
126
140
  }
127
141
  catch (err) {
142
+ const now = new Date();
143
+ const tries = (before.error?.tries ?? 0) + 1;
128
144
  const failed = {
129
145
  copy: before.copy,
130
146
  error: {
131
147
  message: err instanceof ServiceError ? err.message : "Das Senden hat nicht geklappt.",
132
148
  reason: err instanceof ServiceError ? err.data.reason : undefined,
133
- at: new Date().toISOString(),
149
+ at: now.toISOString(),
150
+ tries,
151
+ again: worthAnotherTry(err) ? nextTry(tries, now) : null,
134
152
  },
135
153
  };
136
154
  await writeSetting(key(w.id), failed);
@@ -164,6 +182,30 @@ export async function pushSharing(userId, wizardId) {
164
182
  dailyRunLimit: w.dailyRunLimit,
165
183
  }).catch((err) => console.error("[cloud] sharing not sent:", err.message));
166
184
  }
185
+ /**
186
+ * A new link was made here: the copy in the cloud gets a new one too, and its old one stops
187
+ * answering. Null where no account is linked; the state as it was where the cloud has no copy.
188
+ */
189
+ export async function rotateInCloud(userId, wizardId) {
190
+ if (!(await cloudLinked())) {
191
+ return null;
192
+ }
193
+ const state = await cloudState(wizardId);
194
+ if (!state.copy) {
195
+ return state;
196
+ }
197
+ const w = await ownedWizard(userId, wizardId);
198
+ const answer = await call("POST", `/spaces/${w.projectId}/wizards/${w.id}/rotate-link`);
199
+ if (!answer.ok || !answer.body.shareUrl) {
200
+ throw refusal(answer, "Der Link in der Cloud ließ sich nicht erneuern.");
201
+ }
202
+ const next = {
203
+ ...state,
204
+ copy: { ...state.copy, shareUrl: answer.body.shareUrl, code: answer.body.code },
205
+ };
206
+ await writeSetting(key(w.id), next);
207
+ return next;
208
+ }
167
209
  /** A wizard is about to be deleted here: its copy in the cloud goes too. */
168
210
  export async function removeFromCloud(userId, wizardId) {
169
211
  const state = await cloudState(wizardId);
@@ -177,6 +219,37 @@ export async function removeFromCloud(userId, wizardId) {
177
219
  }
178
220
  await call("DELETE", `/spaces/${w.projectId}/wizards/${w.id}`).catch((err) => console.error("[cloud] copy not removed:", err.message));
179
221
  }
222
+ /**
223
+ * Sends again what did not arrive and is due for another try. Called every minute by the
224
+ * runtime that runs alone; a wizard that is gone meanwhile is forgotten.
225
+ */
226
+ export async function retryFailedSends(userId) {
227
+ if (!(await cloudLinked())) {
228
+ return;
229
+ }
230
+ const now = new Date().toISOString();
231
+ const rows = await controlDb
232
+ .select({ key: control.setting.key, value: control.setting.value })
233
+ .from(control.setting)
234
+ .where(like(control.setting.key, "cloud:%"));
235
+ for (const row of rows) {
236
+ const state = row.value;
237
+ if (!state.error?.again || state.error.again > now) {
238
+ continue;
239
+ }
240
+ const wizardId = row.key.slice("cloud:".length);
241
+ const w = await ownedWizard(userId, wizardId).catch(() => null);
242
+ if (!w?.publishedVersion) {
243
+ await deleteSetting(row.key);
244
+ continue;
245
+ }
246
+ const sent = await syncToCloud(userId, wizardId);
247
+ // Something the person has to see to (signed out, no room) holds for every wizard.
248
+ if (sent.error && !sent.error.again) {
249
+ break;
250
+ }
251
+ }
252
+ }
180
253
  /** The account was unlinked: what this runtime kept of its cloud belongs to no account now. */
181
254
  export async function forgetCloud() {
182
255
  await controlDb.delete(control.setting).where(like(control.setting.key, "cloud:%"));
@@ -189,12 +262,20 @@ export async function syncAllToCloud(userId) {
189
262
  .where(and(eq(schema.wizard.tenantId, currentTenant()), isNotNull(schema.wizard.publishedVersion)));
190
263
  let sent = 0;
191
264
  let failed = 0;
192
- for (const { id } of wizards) {
265
+ for (const [i, { id }] of wizards.entries()) {
193
266
  const state = await syncToCloud(userId, id);
194
267
  if (state.error) {
195
268
  failed++;
196
- // A refusal that holds for every wizard (no room, signed out) is not asked again.
269
+ // A refusal that holds for every wizard (no room, signed out) is not asked again; a cloud
270
+ // out of reach neither, but the rest is then tried again later, with this one.
197
271
  if (state.error.reason && state.error.reason !== "refused") {
272
+ for (const rest of wizards.slice(i + 1)) {
273
+ failed++;
274
+ await writeSetting(key(rest.id), {
275
+ copy: (await cloudState(rest.id)).copy,
276
+ error: state.error,
277
+ });
278
+ }
198
279
  break;
199
280
  }
200
281
  }
@@ -1,5 +1,5 @@
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";
@@ -76,6 +76,55 @@ 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
129
  const w = await writableWizard(userId, wizardId);
81
130
  const path = requirePath(rawPath);
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { linkedAccount } from "../auth/account.js";
2
3
  import { managed } from "../manage.js";
3
4
  import { readSetting, writeSetting } from "../settings.js";
4
5
  export const profileSchema = z.object({
@@ -8,16 +9,30 @@ export const profileSchema = z.object({
8
9
  phone: z.string().trim().max(40),
9
10
  });
10
11
  const key = (user) => `profile:${user.tenantId}:${user.id}`;
12
+ /** The account whose name and e-mail the profile shows; null where the person sets them here. */
13
+ async function accountOf(user) {
14
+ if (managed) {
15
+ return { name: user.name, email: user.email };
16
+ }
17
+ const linked = await linkedAccount();
18
+ return linked ? { name: linked.name, email: linked.email } : null;
19
+ }
11
20
  export async function userProfile(user) {
12
21
  const stored = (await readSetting(key(user))) ?? {};
22
+ const account = await accountOf(user);
13
23
  return {
14
- name: (managed ? "" : stored.name) || user.name,
24
+ name: account?.name || stored.name || user.name,
15
25
  about: stored.about ?? "",
16
- email: (managed ? "" : stored.email) || user.email,
26
+ email: account ? account.email : stored.email || user.email,
17
27
  phone: stored.phone ?? "",
18
28
  };
19
29
  }
20
30
  export async function saveProfile(user, profile) {
21
- await writeSetting(key(user), profile);
31
+ // Name and e-mail an account gives stay the person's own underneath: they come back unlinked.
32
+ const stored = (await readSetting(key(user))) ?? {};
33
+ const own = (await accountOf(user))
34
+ ? { ...profile, name: stored.name ?? "", email: stored.email ?? "" }
35
+ : profile;
36
+ await writeSetting(key(user), own);
22
37
  return userProfile(user);
23
38
  }
@@ -4,7 +4,7 @@ import { WORKSPACE_LIMITS } from "@engenty-wizards/shared/workspace";
4
4
  import { and, count, eq, inArray, sql } from "drizzle-orm";
5
5
  import { nanoid } from "nanoid";
6
6
  import { z } from "zod";
7
- import { db, schema } from "../db/client.js";
7
+ import { db, inTransaction, schema } from "../db/client.js";
8
8
  import { missingModels, unavoidableSteps } from "../engine/requirements.js";
9
9
  import { env } from "../env.js";
10
10
  import { codeOf, putLink } from "../tenants/control.js";
@@ -12,10 +12,10 @@ import { currentTenant } from "../tenants/tenant.js";
12
12
  import { asSync, mayBuild } from "./access.js";
13
13
  import { emitDraftChanged } from "./draft-events.js";
14
14
  import { notFound, ServiceError } from "./errors.js";
15
- import { draftFiles, writeFile } from "./files.js";
15
+ import { keepFiles, replaceFiles } from "./files.js";
16
16
  import { addProjectFile, projectFiles, removeProjectFile } from "./project-files.js";
17
17
  import { brandColorSchema, projectFactSchema, projectLimit, removeProject, } from "./projects.js";
18
- import { deleteWizard, draftIssues, parseDraft, shareUrl } from "./wizards.js";
18
+ import { deleteWizard, draftIssues, parseDraft, rotateShareLink, shareUrl } from "./wizards.js";
19
19
  /**
20
20
  * A local install's project on this runtime (docs/manage-contract.md, "Spaces of a local
21
21
  * install"). The install sends a wizard when it publishes it: the published version and its
@@ -164,7 +164,10 @@ async function requireWizardRoom(spaceId) {
164
164
  }
165
165
  }
166
166
  const hashOf = (data) => createHash("sha256").update(data).digest("hex");
167
- /** The project's logo follows the install's: kept when it is the same picture, else replaced. */
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
+ */
168
171
  async function syncLogo(projectId, logo) {
169
172
  const here = await projectFiles(projectId, "logo");
170
173
  const data = logo ? Buffer.from(logo.data, "base64") : null;
@@ -172,9 +175,6 @@ async function syncLogo(projectId, logo) {
172
175
  if (mark && here.length === 1 && here[0].source === mark) {
173
176
  return;
174
177
  }
175
- for (const old of here) {
176
- await removeProjectFile(projectId, old.id);
177
- }
178
178
  if (logo && data && mark) {
179
179
  await addProjectFile(projectId, {
180
180
  kind: "logo",
@@ -186,8 +186,11 @@ async function syncLogo(projectId, logo) {
186
186
  source: mark,
187
187
  });
188
188
  }
189
+ for (const old of here) {
190
+ await removeProjectFile(projectId, old.id);
191
+ }
189
192
  }
190
- /** Makes or updates the synced project: its name, what it says about itself, its logo. */
193
+ /** Makes or updates the synced project: its name and what it says about itself (the logo: syncLogo). */
191
194
  async function syncSpace(spaceId, space) {
192
195
  const existing = await syncedProject(spaceId);
193
196
  if (existing && existing.origin !== "local") {
@@ -215,7 +218,6 @@ async function syncSpace(spaceId, space) {
215
218
  ...values,
216
219
  });
217
220
  }
218
- await syncLogo(spaceId, space.logo);
219
221
  const project = await syncedProject(spaceId);
220
222
  if (!project) {
221
223
  throw notFound();
@@ -225,86 +227,84 @@ async function syncSpace(spaceId, space) {
225
227
  /**
226
228
  * Takes a wizard a local install published: the project it is in, the wizard under its id, its
227
229
  * workspace, and the version — published here when a run of it can finish. Sending the same
228
- * wizard again updates the copy; its link stays.
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.
229
233
  */
230
- export async function syncWizard(userId, rawSpaceId, rawWizardId, input) {
234
+ export async function syncWizard(rawSpaceId, rawWizardId, input) {
231
235
  const spaceId = syncedId.parse(rawSpaceId);
232
236
  const wizardId = syncedId.parse(rawWizardId);
233
- const paths = input.files.map((f) => f.path);
234
237
  // The shape is checked before anything is written; a wrong one leaves nothing behind.
235
- const { draft } = parseDraft(input.definition, paths);
238
+ const { draft } = parseDraft(input.definition, input.files.map((f) => f.path));
236
239
  return asSync(async () => {
237
- const project = await syncSpace(spaceId, input.space);
238
- const existing = await db.query.wizard.findFirst({
239
- where: and(eq(schema.wizard.id, wizardId), eq(schema.wizard.tenantId, currentTenant())),
240
- });
241
- if (existing && existing.projectId !== spaceId) {
242
- throw new ServiceError("refused", "Ein Wizard mit dieser Kennung gehört hier woanders hin.", {
243
- reason: "id_taken",
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())),
244
245
  });
245
- }
246
- const settings = {
247
- title: draft.title,
248
- draft,
249
- shareEnabled: input.shareEnabled,
250
- ...(input.dailyRunLimit ? { dailyRunLimit: input.dailyRunLimit } : {}),
251
- };
252
- if (!existing) {
253
- await requireWizardRoom(spaceId);
254
- }
255
- let shareToken = existing?.shareToken;
256
- if (existing) {
257
- await db
258
- .update(schema.wizard)
259
- .set({ ...settings, revision: sql `${schema.wizard.revision} + 1`, updatedAt: new Date() })
260
- .where(eq(schema.wizard.id, wizardId));
261
- }
262
- else {
263
- // The link is made here: a token an install brought could be one another tenant holds.
264
- shareToken = nanoid(14);
265
- await db.insert(schema.wizard).values({
266
- id: wizardId,
267
- projectId: spaceId,
268
- tenantId: currentTenant(),
269
- shareToken,
270
- dailyRunLimit: env.limits.defaultDailyRuns,
271
- ...settings,
272
- });
273
- await putLink(shareToken, "wizard", wizardId);
274
- }
275
- // The workspace becomes what was sent: files that are gone there go here.
276
- const before = await draftFiles(wizardId);
277
- const gone = before.filter((f) => !paths.includes(f.path)).map((f) => f.path);
278
- if (gone.length) {
279
- await db
280
- .delete(schema.wizardFile)
281
- .where(and(eq(schema.wizardFile.wizardId, wizardId), inArray(schema.wizardFile.path, gone)));
282
- }
283
- for (const file of input.files) {
284
- await writeFile(userId, wizardId, file.path, Buffer.from(file.data, "base64"), file.mime);
285
- }
286
- const row = await db.query.wizard.findFirst({ where: eq(schema.wizard.id, wizardId) });
287
- if (!row) {
288
- throw notFound();
289
- }
290
- const problems = await serverProblems(draft, await draftIssues(row), project.mcpServers.map((s) => s.id));
291
- const runnable = !problems.some((p) => p.blocking);
292
- let publishedVersion = row.publishedVersion;
293
- if (runnable) {
294
- const files = await draftFiles(wizardId);
295
- await db
296
- .insert(schema.wizardVersion)
297
- .values({ id: nanoid(12), wizardId, version: input.version, definition: draft, files })
298
- .onConflictDoUpdate({
299
- target: [schema.wizardVersion.wizardId, schema.wizardVersion.version],
300
- set: { definition: draft, files },
301
- });
302
- await db
303
- .update(schema.wizard)
304
- .set({ publishedVersion: input.version })
305
- .where(eq(schema.wizard.id, wizardId));
306
- publishedVersion = input.version;
307
- }
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
308
  emitDraftChanged({
309
309
  wizardId,
310
310
  kind: "draft",
@@ -316,8 +316,8 @@ export async function syncWizard(userId, rawSpaceId, rawWizardId, input) {
316
316
  });
317
317
  return {
318
318
  wizardId,
319
- shareUrl: shareUrl(shareToken ?? row.shareToken),
320
- code: await codeOf(shareToken ?? row.shareToken),
319
+ shareUrl: shareUrl(row.shareToken),
320
+ code: await codeOf(row.shareToken),
321
321
  shareEnabled: row.shareEnabled,
322
322
  publishedVersion,
323
323
  runnable,
@@ -348,6 +348,12 @@ export async function patchSyncedWizard(spaceId, wizardId, patch) {
348
348
  .where(eq(schema.wizard.id, w.id));
349
349
  return { ok: true };
350
350
  }
351
+ /** The install made a new link for a wizard: its copy here gets one too, the old one stops. */
352
+ export async function rotateSyncedLink(userId, spaceId, wizardId) {
353
+ const w = await syncedWizard(spaceId, wizardId);
354
+ const { shareToken } = await asSync(() => rotateShareLink(userId, w.id));
355
+ return { shareUrl: shareUrl(shareToken), code: await codeOf(shareToken) };
356
+ }
351
357
  /** The install deleted a wizard: its copy here goes, with its link and its runs. */
352
358
  export async function removeSyncedWizard(userId, spaceId, wizardId) {
353
359
  const w = await syncedWizard(spaceId, wizardId);
@@ -4,8 +4,8 @@ import { join } from "node:path";
4
4
  import { installedByScript, isCheckout, layout, packageRoot, packageVersion } from "./cli/home.js";
5
5
  const RELEASES_URL = process.env.ENGENTY_WIZARDS_RELEASES_URL?.trim() ||
6
6
  "https://api.github.com/repos/engenty/engenty-wizards/releases/latest";
7
- const FOUND_FOR_MS = 6 * 60 * 60_000;
8
- const MISSED_FOR_MS = 60 * 60_000;
7
+ /** How long an answer from GitHub holds, found or not, before the runtime asks again. */
8
+ const CHECK_EVERY_MS = 60 * 60_000;
9
9
  /**
10
10
  * The exit code with which the runtime asks the command that started it for a restart: after an
11
11
  * update, that command starts the new version (cli/start.ts).
@@ -47,7 +47,7 @@ async function newestRelease() {
47
47
  return { latest: null, url: null };
48
48
  }
49
49
  const age = cached ? Date.now() - cached.at : Number.POSITIVE_INFINITY;
50
- if (cached && age < (cached.latest ? FOUND_FOR_MS : MISSED_FOR_MS)) {
50
+ if (cached && age < CHECK_EVERY_MS) {
51
51
  return cached;
52
52
  }
53
53
  let found = { latest: null, url: null };