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.
- package/apps/runtime/dist/db/client.js +24 -3
- package/apps/runtime/dist/index.js +6 -0
- package/apps/runtime/dist/routes/api.js +13 -2
- package/apps/runtime/dist/routes/studio.js +11 -3
- package/apps/runtime/dist/services/cloud.js +84 -3
- package/apps/runtime/dist/services/files.js +50 -1
- package/apps/runtime/dist/services/profile.js +18 -3
- package/apps/runtime/dist/services/spaces.js +91 -85
- package/apps/runtime/dist/update.js +3 -3
- package/apps/web/dist/assets/Account-BKribBzW.js +1 -0
- package/apps/web/dist/assets/{ChangelogDialog-CbZBUMRu.js → ChangelogDialog-CYQC4l57.js} +1 -1
- package/apps/web/dist/assets/{ChatPanel-zurtfj6A.js → ChatPanel-Cnd_dfF2.js} +1 -1
- package/apps/web/dist/assets/{CreditsDialog-BewZHZTR.js → CreditsDialog-7nLXWFvP.js} +1 -1
- package/apps/web/dist/assets/EditorPage-BjFAF_4T.js +2 -0
- package/apps/web/dist/assets/{FlowPreview-DHrhPps0.js → FlowPreview-BT4i-qIF.js} +1 -1
- package/apps/web/dist/assets/HomePage-F04N9Y1Q.js +1 -0
- package/apps/web/dist/assets/{ImportWizard-CKjVJMaG.js → ImportWizard-Cg0l6q88.js} +1 -1
- package/apps/web/dist/assets/{LocalRuntime-Brkvbzzg.js → LocalRuntime-BoVnl1ga.js} +2 -2
- package/apps/web/dist/assets/NewWizardPage-DumPEWtE.js +2 -0
- package/apps/web/dist/assets/{PublicRunner-Bn1g6HG9.js → PublicRunner-CllgpTeL.js} +1 -1
- package/apps/web/dist/assets/{RunnerView-CO1Nk3jn.js → RunnerView-adkYnOSi.js} +1 -1
- package/apps/web/dist/assets/SettingsPage-7EG_dMSP.js +2 -0
- package/apps/web/dist/assets/{SetupPage-BvLwZH9i.js → SetupPage-BzZMf7Oz.js} +1 -1
- package/apps/web/dist/assets/{SharePage-CDESJ11B.js → SharePage-CONXJnOU.js} +1 -1
- package/apps/web/dist/assets/{api-9sHuNTkp.js → api-BC3K8Na8.js} +1 -1
- package/apps/web/dist/assets/changelog-C0GmxRP2.js +1 -0
- package/apps/web/dist/assets/cloud-off-D--dZLDw.js +1 -0
- package/apps/web/dist/assets/index-CJ3iuK8-.js +10 -0
- package/apps/web/dist/assets/index-mpjxO44v.css +1 -0
- package/apps/web/dist/assets/{meta-B4mZqEso.js → meta-BeYxDfVN.js} +1 -1
- package/apps/web/dist/assets/{outputs-boPx5ngv.js → outputs-DLmQMIUm.js} +1 -1
- package/apps/web/dist/assets/{session-CJGuo1Tv.js → session-_w8H5O_d.js} +1 -1
- package/apps/web/dist/assets/type-DnfgUhpM.js +1 -0
- package/apps/web/dist/assets/ui-Cj3EzOCA.js +1 -0
- package/apps/web/dist/assets/upload-DNRENWzQ.js +1 -0
- package/apps/web/dist/index.html +6 -5
- package/apps/web/dist/mcp/flow.html +2 -2
- package/package.json +1 -1
- package/apps/web/dist/assets/EditorPage-B7ZOoW5h.js +0 -2
- package/apps/web/dist/assets/HomePage-CBvWxXsq.js +0 -1
- package/apps/web/dist/assets/NewWizardPage-B3XePMlp.js +0 -2
- package/apps/web/dist/assets/SettingsPage-BIY4kHZl.js +0 -2
- package/apps/web/dist/assets/changelog-Cto0j5-V.js +0 -1
- package/apps/web/dist/assets/index-CIwJmOjo.css +0 -1
- package/apps/web/dist/assets/index-VsRgvimv.js +0 -10
- package/apps/web/dist/assets/type-BMJpS6_D.js +0 -1
- package/apps/web/dist/assets/ui-iTYHqwvY.js +0 -1
- 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
|
-
*
|
|
122
|
-
*
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
24
|
+
name: account?.name || stored.name || user.name,
|
|
15
25
|
about: stored.about ?? "",
|
|
16
|
-
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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
|
|
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(
|
|
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,
|
|
238
|
+
const { draft } = parseDraft(input.definition, input.files.map((f) => f.path));
|
|
236
239
|
return asSync(async () => {
|
|
237
|
-
const
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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(
|
|
320
|
-
code: await codeOf(
|
|
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
|
-
|
|
8
|
-
const
|
|
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 <
|
|
50
|
+
if (cached && age < CHECK_EVERY_MS) {
|
|
51
51
|
return cached;
|
|
52
52
|
}
|
|
53
53
|
let found = { latest: null, url: null };
|