ima2-gen 2.0.15 → 2.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/bin/commands/skill.js +336 -17
- package/bin/ima2.js +22 -2
- package/bin/lib/doctor-checks.js +6 -4
- package/config.js +1 -0
- package/docs/CLI.md +25 -3
- package/docs/PROMPT_STUDIO.ko.md +29 -0
- package/docs/PROMPT_STUDIO.md +31 -0
- package/docs/migration/runtime-test-inventory.md +7 -1
- package/lib/agentImageVideoGen.js +8 -0
- package/lib/agentQueueStore.js +36 -10
- package/lib/agentQueueWorker.js +79 -7
- package/lib/agentRuntime.js +2 -0
- package/lib/assetLifecycle.js +40 -8
- package/lib/canvasVersionStore.js +86 -0
- package/lib/cardNewsGenerator.js +37 -3
- package/lib/cardNewsJobStore.js +10 -0
- package/lib/cardNewsManifestStore.js +6 -12
- package/lib/cardNewsPath.js +25 -0
- package/lib/db.js +2 -0
- package/lib/generatePipeline.js +474 -0
- package/lib/generationInputValidation.js +32 -0
- package/lib/grokImageCore.js +18 -1
- package/lib/historyIndex.js +11 -4
- package/lib/historyList.js +3 -0
- package/lib/imageMetadata.js +3 -0
- package/lib/inflight.js +30 -3
- package/lib/multimodePipeline.js +462 -0
- package/lib/nodeGeneration.js +440 -0
- package/lib/nodeValidation.js +23 -0
- package/lib/oauthProxy/generators.js +3 -246
- package/lib/oauthProxy/multimodeGenerators.js +251 -0
- package/lib/routeHelpers.js +9 -2
- package/package.json +2 -2
- package/routes/agent.js +4 -4
- package/routes/canvasVersions.js +21 -1
- package/routes/cardNews.js +2 -1
- package/routes/edit.js +25 -9
- package/routes/events.js +5 -1
- package/routes/generate.js +2 -481
- package/routes/keys.js +31 -39
- package/routes/multimode.js +2 -502
- package/routes/nodes.js +3 -448
- package/routes/video.js +12 -3
- package/routes/videoExtended.js +19 -4
- package/server.js +53 -1
- package/skills/ima2/SKILL.md +204 -0
- package/skills/ima2-front/SKILL.md +611 -0
- package/skills/ima2-front/references/a11y-patterns.md +134 -0
- package/skills/ima2-front/references/aesthetics.md +327 -0
- package/skills/ima2-front/references/anti-slop.md +241 -0
- package/skills/ima2-front/references/asset-requirements.md +431 -0
- package/skills/ima2-front/references/brand-asset-sourcing.md +122 -0
- package/skills/ima2-front/references/color-system.md +132 -0
- package/skills/ima2-front/references/consistency-locks.md +20 -0
- package/skills/ima2-front/references/crud-ui.md +53 -0
- package/skills/ima2-front/references/dropdown-layer.md +110 -0
- package/skills/ima2-front/references/i18n-global.md +104 -0
- package/skills/ima2-front/references/iterative-design.md +90 -0
- package/skills/ima2-front/references/korea-2026.md +174 -0
- package/skills/ima2-front/references/layout-discipline.md +161 -0
- package/skills/ima2-front/references/liquid-glass.md +181 -0
- package/skills/ima2-front/references/logo-sections.md +194 -0
- package/skills/ima2-front/references/mobile-ux.md +144 -0
- package/skills/ima2-front/references/performance-budget.md +106 -0
- package/skills/ima2-front/references/preflight-full.md +58 -0
- package/skills/ima2-front/references/product-density.md +53 -0
- package/skills/ima2-front/references/prototype-variants.md +54 -0
- package/skills/ima2-front/references/reference-capture.md +120 -0
- package/skills/ima2-front/references/responsive-viewport.md +139 -0
- package/skills/ima2-front/references/seo-baseline.md +123 -0
- package/skills/ima2-front/references/soft-3d-asset-gates.md +70 -0
- package/skills/ima2-front/references/stacks/astro.md +204 -0
- package/skills/ima2-front/references/stacks/mobile-native.md +281 -0
- package/skills/ima2-front/references/stacks/nextjs.md +219 -0
- package/skills/ima2-front/references/stacks/react.md +264 -0
- package/skills/ima2-front/references/stacks/svelte.md +235 -0
- package/skills/ima2-front/references/stacks/vanilla.md +226 -0
- package/skills/ima2-front/references/theme-switching.md +82 -0
- package/skills/ima2-front/references/top-bar.md +126 -0
- package/skills/ima2-front/references/typography-wrapping.md +270 -0
- package/skills/ima2-front/references/ux-writing-ko.md +62 -0
- package/skills/ima2-front/references/visual-verification.md +102 -0
- package/skills/ima2-uiux/SKILL.md +477 -0
- package/skills/ima2-uiux/references/anti-slop-judgment.md +39 -0
- package/skills/ima2-uiux/references/color-system.md +80 -0
- package/skills/ima2-uiux/references/design-isms.md +194 -0
- package/skills/ima2-uiux/references/design-read-example.md +33 -0
- package/skills/ima2-uiux/references/design-system-bootstrap.md +133 -0
- package/skills/ima2-uiux/references/favicon-logo.md +355 -0
- package/skills/ima2-uiux/references/form-patterns.md +118 -0
- package/skills/ima2-uiux/references/intent-discovery-ladder.md +86 -0
- package/skills/ima2-uiux/references/korean-design-vocabulary.md +52 -0
- package/skills/ima2-uiux/references/layout-macrostructures.md +54 -0
- package/skills/ima2-uiux/references/logo-trust-sections.md +96 -0
- package/skills/ima2-uiux/references/mobile-native-ux.md +207 -0
- package/skills/ima2-uiux/references/product-personalities.md +118 -0
- package/skills/ima2-uiux/references/responsive-nav.md +66 -0
- package/skills/ima2-uiux/references/typography-line-breaks.md +193 -0
- package/skills/ima2-uiux/references/ux-preflight.md +65 -0
- package/skills/ima2-uiux/references/ux-states.md +197 -0
- package/skills/ima2-uiux/references/visual-hierarchy.md +115 -0
- package/ui/dist/.vite/manifest.json +13 -13
- package/ui/dist/assets/AgentWorkspace-CtYt4SF5.js +3 -0
- package/ui/dist/assets/{CardNewsWorkspace-Cdxn1ohU.js → CardNewsWorkspace-DN5cVqG5.js} +1 -1
- package/ui/dist/assets/GenerationRequestLogPanel-DnmUQdKR.js +1 -0
- package/ui/dist/assets/{NodeCanvas-C67sLboA.js → NodeCanvas-CxpfIBI0.js} +1 -1
- package/ui/dist/assets/PromptBuilderPanel-C2R3ObtR.js +2 -0
- package/ui/dist/assets/{PromptImportDialog-DeQXsEEv.js → PromptImportDialog-BIxz_yEx.js} +2 -2
- package/ui/dist/assets/PromptImportDiscoverySection-BQTlPMME.js +1 -0
- package/ui/dist/assets/PromptImportFolderSection-Ds_Th9O3.js +1 -0
- package/ui/dist/assets/{PromptLibraryPanel-Db3xEPM_.js → PromptLibraryPanel-Rs4l45pV.js} +2 -2
- package/ui/dist/assets/{SettingsWorkspace-BDk_1W-V.js → SettingsWorkspace-p1AZ6uT9.js} +1 -1
- package/ui/dist/assets/index-0-_vgFGs.js +4 -0
- package/ui/dist/assets/index-Dm3pFxV4.js +23 -0
- package/ui/dist/assets/index-J8yDF3Ch.css +1 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/AgentWorkspace-DAg_7zpY.js +0 -3
- package/ui/dist/assets/GenerationRequestLogPanel-C_qvEUi2.js +0 -1
- package/ui/dist/assets/PromptBuilderPanel-DjvVdAie.js +0 -2
- package/ui/dist/assets/PromptImportDiscoverySection-CrwyUKYI.js +0 -1
- package/ui/dist/assets/PromptImportFolderSection-CToGMqYE.js +0 -1
- package/ui/dist/assets/index-2oG6in1i.css +0 -1
- package/ui/dist/assets/index-CoxMOXAU.js +0 -23
- package/ui/dist/assets/index-p8egnGHk.js +0 -4
|
@@ -19,6 +19,7 @@ import { errInfo } from "./errInfo.js";
|
|
|
19
19
|
import { forceImagePrompt, isTextOnlyResult, textOnlyError, notFound } from "./agentRuntime.js";
|
|
20
20
|
const AGENT_GROK_PLANNER_MODEL = "grok-4.3";
|
|
21
21
|
export async function generateAgentImageWithRetry(ctx, sessionId, prompt, manifest, webSearchEnabled, options) {
|
|
22
|
+
options.onProgressStage?.("requesting");
|
|
22
23
|
let lastError = null;
|
|
23
24
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
24
25
|
try {
|
|
@@ -200,6 +201,7 @@ export async function runAgentVideoGeneration(ctx, sessionId, prompt, options =
|
|
|
200
201
|
const videoModel = videoParams.resolution === "1080p"
|
|
201
202
|
? GROK_VIDEO_MODEL_15
|
|
202
203
|
: GROK_VIDEO_MODEL_BASE;
|
|
204
|
+
options.onProgressStage?.("requesting");
|
|
203
205
|
const result = await generateVideoViaGrok(prompt, ctx, {
|
|
204
206
|
model: videoModel,
|
|
205
207
|
mode,
|
|
@@ -210,7 +212,13 @@ export async function runAgentVideoGeneration(ctx, sessionId, prompt, options =
|
|
|
210
212
|
requestId,
|
|
211
213
|
signal: options.signal ?? undefined,
|
|
212
214
|
plannerModel: isAgentGrokPlannerModel(options.model) ? options.model : undefined,
|
|
215
|
+
onEvent: (event) => {
|
|
216
|
+
if (event.phase === "submitted" || event.phase === "progress") {
|
|
217
|
+
options.onProgressStage?.("polling");
|
|
218
|
+
}
|
|
219
|
+
},
|
|
213
220
|
});
|
|
221
|
+
options.onProgressStage?.("downloading");
|
|
214
222
|
const video = await persistAgentVideo(ctx, sessionId, prompt, requestId, result);
|
|
215
223
|
const finishedAt = Date.now();
|
|
216
224
|
const toolCall = {
|
package/lib/agentQueueStore.js
CHANGED
|
@@ -32,6 +32,7 @@ export function getAgentQueueItem(id) {
|
|
|
32
32
|
result_image_ids AS resultImageIds,
|
|
33
33
|
error_code AS errorCode,
|
|
34
34
|
error_message AS errorMessage,
|
|
35
|
+
progress_stage AS progressStage,
|
|
35
36
|
created_at AS createdAt,
|
|
36
37
|
started_at AS startedAt,
|
|
37
38
|
finished_at AS finishedAt
|
|
@@ -55,6 +56,7 @@ export function listAgentQueueItems(sessionId) {
|
|
|
55
56
|
result_image_ids AS resultImageIds,
|
|
56
57
|
error_code AS errorCode,
|
|
57
58
|
error_message AS errorMessage,
|
|
59
|
+
progress_stage AS progressStage,
|
|
58
60
|
created_at AS createdAt,
|
|
59
61
|
started_at AS startedAt,
|
|
60
62
|
finished_at AS finishedAt
|
|
@@ -93,6 +95,7 @@ export function claimNextAgentQueueItem(limits) {
|
|
|
93
95
|
result_image_ids AS resultImageIds,
|
|
94
96
|
error_code AS errorCode,
|
|
95
97
|
error_message AS errorMessage,
|
|
98
|
+
progress_stage AS progressStage,
|
|
96
99
|
created_at AS createdAt,
|
|
97
100
|
started_at AS startedAt,
|
|
98
101
|
finished_at AS finishedAt
|
|
@@ -106,7 +109,7 @@ export function claimNextAgentQueueItem(limits) {
|
|
|
106
109
|
continue;
|
|
107
110
|
const res = getDb().prepare(`
|
|
108
111
|
UPDATE agent_queue_items
|
|
109
|
-
SET status = 'running', started_at = ?, error_code = NULL, error_message = NULL
|
|
112
|
+
SET status = 'running', started_at = ?, progress_stage = NULL, error_code = NULL, error_message = NULL
|
|
110
113
|
WHERE id = ? AND status = 'queued'
|
|
111
114
|
`).run(Date.now(), row.id);
|
|
112
115
|
if (res.changes > 0)
|
|
@@ -120,9 +123,10 @@ export function completeAgentQueueItem(id, imageIds) {
|
|
|
120
123
|
SET status = 'succeeded',
|
|
121
124
|
result_image_ids = ?,
|
|
122
125
|
finished_at = ?,
|
|
126
|
+
progress_stage = NULL,
|
|
123
127
|
error_code = NULL,
|
|
124
128
|
error_message = NULL
|
|
125
|
-
WHERE id = ?
|
|
129
|
+
WHERE id = ? AND status = 'running'
|
|
126
130
|
`).run(JSON.stringify([...imageIds]), Date.now(), id);
|
|
127
131
|
}
|
|
128
132
|
export function failAgentQueueItem(id, error) {
|
|
@@ -131,18 +135,35 @@ export function failAgentQueueItem(id, error) {
|
|
|
131
135
|
SET status = 'failed',
|
|
132
136
|
error_code = ?,
|
|
133
137
|
error_message = ?,
|
|
134
|
-
finished_at =
|
|
135
|
-
|
|
138
|
+
finished_at = ?,
|
|
139
|
+
progress_stage = NULL
|
|
140
|
+
WHERE id = ? AND status = 'running'
|
|
136
141
|
`).run(error.code ?? "AGENT_QUEUE_FAILED", error.message, Date.now(), id);
|
|
137
142
|
}
|
|
138
|
-
export function cancelAgentQueueItem(id) {
|
|
143
|
+
export function cancelAgentQueueItem(id, reason = "Canceled by user") {
|
|
139
144
|
const res = getDb().prepare(`
|
|
140
145
|
UPDATE agent_queue_items
|
|
141
|
-
SET status = 'canceled', finished_at =
|
|
142
|
-
WHERE id = ? AND status
|
|
143
|
-
`).run(Date.now(), id);
|
|
146
|
+
SET status = 'canceled', error_code = 'canceled', error_message = ?, finished_at = ?, progress_stage = NULL
|
|
147
|
+
WHERE id = ? AND status IN ('queued', 'running')
|
|
148
|
+
`).run(reason, Date.now(), id);
|
|
144
149
|
return res.changes > 0;
|
|
145
150
|
}
|
|
151
|
+
export function updateAgentQueueItemProgress(id, stage) {
|
|
152
|
+
const res = getDb().prepare(`
|
|
153
|
+
UPDATE agent_queue_items SET progress_stage = ?
|
|
154
|
+
WHERE id = ? AND status = 'running'
|
|
155
|
+
`).run(stage ?? null, id);
|
|
156
|
+
return res.changes > 0;
|
|
157
|
+
}
|
|
158
|
+
export function recoverRunningAgentQueueItems() {
|
|
159
|
+
const res = getDb().prepare(`
|
|
160
|
+
UPDATE agent_queue_items
|
|
161
|
+
SET status = 'failed', error_code = 'server_restart', error_message = 'server restarted mid-run',
|
|
162
|
+
finished_at = ?, progress_stage = NULL
|
|
163
|
+
WHERE status = 'running'
|
|
164
|
+
`).run(Date.now());
|
|
165
|
+
return res.changes;
|
|
166
|
+
}
|
|
146
167
|
export function updateAgentQueueItemPlan(id, plan) {
|
|
147
168
|
const res = getDb().prepare(`
|
|
148
169
|
UPDATE agent_queue_items
|
|
@@ -193,6 +214,7 @@ export function retryAgentQueueItem(id) {
|
|
|
193
214
|
result_image_ids = '[]',
|
|
194
215
|
error_code = NULL,
|
|
195
216
|
error_message = NULL,
|
|
217
|
+
progress_stage = NULL,
|
|
196
218
|
started_at = NULL,
|
|
197
219
|
finished_at = NULL
|
|
198
220
|
WHERE id = ? AND status IN ('failed', 'canceled')
|
|
@@ -211,6 +233,7 @@ function queueItemFromRow(row) {
|
|
|
211
233
|
resultImageIds: parseStringArray(row.resultImageIds),
|
|
212
234
|
errorCode: row.errorCode,
|
|
213
235
|
errorMessage: row.errorMessage,
|
|
236
|
+
progressStage: row.progressStage ?? null,
|
|
214
237
|
createdAt: row.createdAt,
|
|
215
238
|
startedAt: row.startedAt,
|
|
216
239
|
finishedAt: row.finishedAt,
|
|
@@ -218,10 +241,13 @@ function queueItemFromRow(row) {
|
|
|
218
241
|
plan: normalizeAgentGenerationPlan(row.prompt, parseJson(row.toolPlan, {}), options),
|
|
219
242
|
};
|
|
220
243
|
}
|
|
221
|
-
function summarizeQueue(items) {
|
|
244
|
+
export function summarizeQueue(items) {
|
|
222
245
|
const queuedCount = items.filter((item) => item.status === "queued").length;
|
|
223
246
|
const runningCount = items.filter((item) => item.status === "running").length;
|
|
224
|
-
const
|
|
247
|
+
const latestTerminal = items
|
|
248
|
+
.filter((item) => ["succeeded", "failed", "canceled"].includes(item.status))
|
|
249
|
+
.sort((a, b) => (b.finishedAt ?? b.createdAt) - (a.finishedAt ?? a.createdAt))[0];
|
|
250
|
+
const failed = latestTerminal?.status === "failed" ? latestTerminal : null;
|
|
225
251
|
return {
|
|
226
252
|
status: runningCount > 0 ? "running" : queuedCount > 0 ? "queued" : failed ? "error" : "idle",
|
|
227
253
|
queuedCount,
|
package/lib/agentQueueWorker.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { config } from "../config.js";
|
|
2
|
-
import { claimNextAgentQueueItem, completeAgentQueueItem, failAgentQueueItem, getAgentQueueItem, updateAgentQueueItemPlan, } from "./agentQueueStore.js";
|
|
2
|
+
import { claimNextAgentQueueItem, completeAgentQueueItem, failAgentQueueItem, getAgentQueueItem, cancelAgentQueueItem, recoverRunningAgentQueueItems, updateAgentQueueItemProgress, updateAgentQueueItemPlan, } from "./agentQueueStore.js";
|
|
3
3
|
import { requestAgentPlanFromModel } from "./agentPlannerModel.js";
|
|
4
4
|
import { hasAgentErrorTurnRecorded, runAgentGenerationPlan } from "./agentRuntime.js";
|
|
5
5
|
import { appendAgentTurn } from "./agentStore.js";
|
|
6
6
|
import { errInfo } from "./errInfo.js";
|
|
7
|
-
import { finishJob, setJobPhase, startJob } from "./inflight.js";
|
|
7
|
+
import { finishJob, isStartJobFailure, setJobPhase, startJob } from "./inflight.js";
|
|
8
8
|
import { logEvent } from "./logger.js";
|
|
9
9
|
const DEFAULT_LIMITS = {
|
|
10
10
|
maxGlobalRunning: 2,
|
|
@@ -12,7 +12,15 @@ const DEFAULT_LIMITS = {
|
|
|
12
12
|
};
|
|
13
13
|
let workerTimer = null;
|
|
14
14
|
let ticking = false;
|
|
15
|
+
let startupRecovered = false;
|
|
16
|
+
const runningControllers = new Map();
|
|
17
|
+
const IMAGE_TIMEOUT_MS = 10 * 60 * 1_000;
|
|
18
|
+
const VIDEO_TIMEOUT_MS = 30 * 60 * 1_000;
|
|
15
19
|
export function ensureAgentQueueWorker(ctx) {
|
|
20
|
+
if (!startupRecovered) {
|
|
21
|
+
recoverRunningAgentQueueItems();
|
|
22
|
+
startupRecovered = true;
|
|
23
|
+
}
|
|
16
24
|
if (workerTimer)
|
|
17
25
|
return;
|
|
18
26
|
workerTimer = setInterval(() => {
|
|
@@ -26,6 +34,18 @@ export function stopAgentQueueWorker() {
|
|
|
26
34
|
clearInterval(workerTimer);
|
|
27
35
|
workerTimer = null;
|
|
28
36
|
}
|
|
37
|
+
for (const controller of runningControllers.values())
|
|
38
|
+
controller.abort("Worker stopped");
|
|
39
|
+
runningControllers.clear();
|
|
40
|
+
}
|
|
41
|
+
export function cancelRunningAgentQueueItem(id, reason = "Canceled by user") {
|
|
42
|
+
const item = getAgentQueueItem(id);
|
|
43
|
+
if (!item || (item.status !== "queued" && item.status !== "running"))
|
|
44
|
+
return false;
|
|
45
|
+
const canceled = cancelAgentQueueItem(id, reason);
|
|
46
|
+
if (canceled && item.status === "running")
|
|
47
|
+
runningControllers.get(id)?.abort(reason);
|
|
48
|
+
return canceled;
|
|
29
49
|
}
|
|
30
50
|
export async function tickAgentQueueWorker(ctx) {
|
|
31
51
|
if (ticking)
|
|
@@ -80,8 +100,19 @@ async function runClaimedQueueItem(ctx, itemId) {
|
|
|
80
100
|
const item = getAgentQueueItem(itemId);
|
|
81
101
|
if (!item)
|
|
82
102
|
return;
|
|
103
|
+
const controller = new AbortController();
|
|
104
|
+
runningControllers.set(item.id, controller);
|
|
83
105
|
const plan = await resolveRuntimePlan(ctx, item);
|
|
84
|
-
|
|
106
|
+
if (controller.signal.aborted) {
|
|
107
|
+
runningControllers.delete(item.id);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const timeoutMs = plan.mode === "video" ? VIDEO_TIMEOUT_MS : IMAGE_TIMEOUT_MS;
|
|
111
|
+
const timeoutController = new AbortController();
|
|
112
|
+
const timeout = setTimeout(() => timeoutController.abort("timeout"), timeoutMs);
|
|
113
|
+
timeout.unref?.();
|
|
114
|
+
const signal = combineAbortSignals(controller.signal, timeoutController.signal);
|
|
115
|
+
const started = startJob({
|
|
85
116
|
requestId: item.requestId,
|
|
86
117
|
kind: "agent_queue",
|
|
87
118
|
prompt: item.prompt,
|
|
@@ -93,6 +124,15 @@ async function runClaimedQueueItem(ctx, itemId) {
|
|
|
93
124
|
requestedVariants: plan.requestedVariants,
|
|
94
125
|
},
|
|
95
126
|
});
|
|
127
|
+
if (started && isStartJobFailure(started)) {
|
|
128
|
+
clearTimeout(timeout);
|
|
129
|
+
runningControllers.delete(item.id);
|
|
130
|
+
const reason = started.code === "TOO_MANY_JOBS"
|
|
131
|
+
? "Too many concurrent generation jobs"
|
|
132
|
+
: "Request ID already in use";
|
|
133
|
+
failAgentQueueItem(item.id, { code: started.code, message: reason });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
96
136
|
try {
|
|
97
137
|
logEvent("agent_queue", "start", { itemId: item.id, sessionId: item.sessionId });
|
|
98
138
|
setJobPhase(item.requestId, "streaming");
|
|
@@ -101,6 +141,8 @@ async function runClaimedQueueItem(ctx, itemId) {
|
|
|
101
141
|
requestId: item.requestId,
|
|
102
142
|
webSearchEnabled: item.options.webSearchEnabled,
|
|
103
143
|
parallelism: plan.plannedParallelism,
|
|
144
|
+
signal,
|
|
145
|
+
onProgressStage: (stage) => updateAgentQueueItemProgress(item.id, stage),
|
|
104
146
|
}, {
|
|
105
147
|
appendUserTurn: false,
|
|
106
148
|
});
|
|
@@ -112,8 +154,20 @@ async function runClaimedQueueItem(ctx, itemId) {
|
|
|
112
154
|
logEvent("agent_queue", "finish", { itemId: item.id, imageCount: result.imageIds.length });
|
|
113
155
|
}
|
|
114
156
|
catch (error) {
|
|
157
|
+
const current = getAgentQueueItem(item.id);
|
|
158
|
+
if (current?.status === "canceled") {
|
|
159
|
+
finishJob(item.requestId, {
|
|
160
|
+
status: "canceled",
|
|
161
|
+
errorCode: "canceled",
|
|
162
|
+
meta: { queueItemId: item.id },
|
|
163
|
+
});
|
|
164
|
+
logEvent("agent_queue", "canceled", { itemId: item.id, reason: current.errorMessage });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
115
167
|
const err = errInfo(error);
|
|
116
|
-
|
|
168
|
+
const timedOut = timeoutController.signal.aborted;
|
|
169
|
+
const failure = timedOut ? { code: "timeout", message: "timeout" } : { code: err.code, message: err.message };
|
|
170
|
+
failAgentQueueItem(item.id, failure);
|
|
117
171
|
// The chat pane must never fail silently — surface the failure as an
|
|
118
172
|
// assistant error turn unless the runtime already recorded one.
|
|
119
173
|
if (!hasAgentErrorTurnRecorded(error)) {
|
|
@@ -121,7 +175,7 @@ async function runClaimedQueueItem(ctx, itemId) {
|
|
|
121
175
|
appendAgentTurn({
|
|
122
176
|
sessionId: item.sessionId,
|
|
123
177
|
role: "assistant",
|
|
124
|
-
text:
|
|
178
|
+
text: failure.code ? `${failure.message} [${failure.code}]` : failure.message,
|
|
125
179
|
status: "error",
|
|
126
180
|
});
|
|
127
181
|
}
|
|
@@ -131,9 +185,27 @@ async function runClaimedQueueItem(ctx, itemId) {
|
|
|
131
185
|
}
|
|
132
186
|
finishJob(item.requestId, {
|
|
133
187
|
status: "failed",
|
|
134
|
-
errorCode:
|
|
188
|
+
errorCode: failure.code,
|
|
135
189
|
meta: { queueItemId: item.id },
|
|
136
190
|
});
|
|
137
|
-
logEvent("agent_queue", "error", { itemId: item.id, code:
|
|
191
|
+
logEvent("agent_queue", "error", { itemId: item.id, code: failure.code, message: failure.message });
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
clearTimeout(timeout);
|
|
195
|
+
runningControllers.delete(item.id);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function combineAbortSignals(...signals) {
|
|
199
|
+
const controller = new AbortController();
|
|
200
|
+
const abort = (signal) => {
|
|
201
|
+
if (!controller.signal.aborted)
|
|
202
|
+
controller.abort(signal.reason);
|
|
203
|
+
};
|
|
204
|
+
for (const signal of signals) {
|
|
205
|
+
if (signal.aborted)
|
|
206
|
+
abort(signal);
|
|
207
|
+
else
|
|
208
|
+
signal.addEventListener("abort", () => abort(signal), { once: true });
|
|
138
209
|
}
|
|
210
|
+
return controller.signal;
|
|
139
211
|
}
|
package/lib/agentRuntime.js
CHANGED
|
@@ -237,6 +237,8 @@ async function runGeneratorWithRuntimeRecovery(ctx, sessionId, prompt, manifest,
|
|
|
237
237
|
return await generateAgentImageWithRetry(ctx, sessionId, prompt, manifest, webSearchEnabled, options);
|
|
238
238
|
}
|
|
239
239
|
catch (error) {
|
|
240
|
+
if (options.signal?.aborted)
|
|
241
|
+
throw error;
|
|
240
242
|
const err = errInfo(error);
|
|
241
243
|
if (isRuntimeRestartableError(error)) {
|
|
242
244
|
restartAgentRuntimeSession(sessionId, err.code || err.message);
|
package/lib/assetLifecycle.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getDb } from "./db.js";
|
|
2
|
-
import { mkdir, rename, unlink,
|
|
2
|
+
import { mkdir, rename, unlink, lstat, realpath } from "fs/promises";
|
|
3
3
|
import { resolve, sep } from "path";
|
|
4
4
|
import { moveToSystemTrash } from "./systemTrash.js";
|
|
5
5
|
import { config } from "../config.js";
|
|
@@ -27,6 +27,23 @@ function resolveInGenerated(rootDir, relPath) {
|
|
|
27
27
|
}
|
|
28
28
|
return target;
|
|
29
29
|
}
|
|
30
|
+
async function assertRegularGeneratedPath(path) {
|
|
31
|
+
const baseDir = await realpath(resolve(config.storage.generatedDir));
|
|
32
|
+
const stat = await lstat(path);
|
|
33
|
+
if (stat.isSymbolicLink()) {
|
|
34
|
+
const err = new Error("symbolic links are not valid assets");
|
|
35
|
+
err.status = 400;
|
|
36
|
+
err.code = "INVALID_FILENAME";
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
const canonical = await realpath(path);
|
|
40
|
+
if (canonical !== baseDir && !canonical.startsWith(baseDir + sep)) {
|
|
41
|
+
const err = new Error("filename escapes generated/");
|
|
42
|
+
err.status = 400;
|
|
43
|
+
err.code = "INVALID_FILENAME";
|
|
44
|
+
throw err;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
30
47
|
function nodesReferencingFilename(filename) {
|
|
31
48
|
// The client stores imageUrl as `/generated/<encoded filename>` in node data JSON.
|
|
32
49
|
// We scan all sessions' nodes for substring match on the decoded and encoded forms.
|
|
@@ -72,9 +89,11 @@ function markNodesAssetMissing(filename) {
|
|
|
72
89
|
export async function trashAsset(rootDir, filename) {
|
|
73
90
|
const src = resolveInGenerated(rootDir, filename);
|
|
74
91
|
try {
|
|
75
|
-
await
|
|
92
|
+
await assertRegularGeneratedPath(src);
|
|
76
93
|
}
|
|
77
|
-
catch {
|
|
94
|
+
catch (cause) {
|
|
95
|
+
if (cause?.code !== "ENOENT")
|
|
96
|
+
throw cause;
|
|
78
97
|
const err = new Error("Asset not found");
|
|
79
98
|
err.status = 404;
|
|
80
99
|
err.code = "ASSET_NOT_FOUND";
|
|
@@ -83,10 +102,13 @@ export async function trashAsset(rootDir, filename) {
|
|
|
83
102
|
const sidecar = `${src}.json`;
|
|
84
103
|
const paths = [src];
|
|
85
104
|
try {
|
|
86
|
-
await
|
|
105
|
+
await assertRegularGeneratedPath(sidecar);
|
|
87
106
|
paths.push(sidecar);
|
|
88
107
|
}
|
|
89
|
-
catch {
|
|
108
|
+
catch (err) {
|
|
109
|
+
if (err?.code !== "ENOENT")
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
90
112
|
let trashMethod = "system";
|
|
91
113
|
try {
|
|
92
114
|
await moveToSystemTrash(paths);
|
|
@@ -114,16 +136,26 @@ export async function trashAsset(rootDir, filename) {
|
|
|
114
136
|
export async function deleteAssetPermanent(rootDir, filename) {
|
|
115
137
|
const src = resolveInGenerated(rootDir, filename);
|
|
116
138
|
try {
|
|
117
|
-
await
|
|
139
|
+
await assertRegularGeneratedPath(src);
|
|
118
140
|
}
|
|
119
|
-
catch {
|
|
141
|
+
catch (cause) {
|
|
142
|
+
if (cause?.code !== "ENOENT")
|
|
143
|
+
throw cause;
|
|
120
144
|
const err = new Error("Asset not found");
|
|
121
145
|
err.status = 404;
|
|
122
146
|
err.code = "ASSET_NOT_FOUND";
|
|
123
147
|
throw err;
|
|
124
148
|
}
|
|
125
149
|
await unlink(src);
|
|
126
|
-
|
|
150
|
+
const sidecar = src + ".json";
|
|
151
|
+
try {
|
|
152
|
+
await assertRegularGeneratedPath(sidecar);
|
|
153
|
+
await unlink(sidecar);
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
if (err?.code !== "ENOENT")
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
127
159
|
const summary = markNodesAssetMissing(filename);
|
|
128
160
|
return {
|
|
129
161
|
ok: true,
|
|
@@ -2,6 +2,7 @@ import { mkdir, writeFile, access, readFile } from "fs/promises";
|
|
|
2
2
|
import { constants } from "fs";
|
|
3
3
|
import { basename, join, normalize, parse } from "path";
|
|
4
4
|
import { randomBytes } from "crypto";
|
|
5
|
+
import sharp from "sharp";
|
|
5
6
|
import { embedImageMetadataBestEffort } from "./imageMetadataStore.js";
|
|
6
7
|
import { invalidateHistoryIndex } from "./historyIndex.js";
|
|
7
8
|
const PNG_SIGNATURE = "89504e470d0a1a0a";
|
|
@@ -99,8 +100,20 @@ function toGenerateItem(filename, meta) {
|
|
|
99
100
|
canvasVersion: true,
|
|
100
101
|
canvasSourceFilename: meta.canvasSourceFilename || null,
|
|
101
102
|
canvasEditableFilename: filename,
|
|
103
|
+
annotationsBaked: Boolean(meta.annotationsBaked),
|
|
104
|
+
annotationSnapshot: meta.annotationSnapshot ?? null,
|
|
105
|
+
annotationOnly: Boolean(meta.annotationOnly),
|
|
102
106
|
};
|
|
103
107
|
}
|
|
108
|
+
function assertAnnotationSnapshot(value) {
|
|
109
|
+
const snapshot = value;
|
|
110
|
+
if (!snapshot || !Array.isArray(snapshot.paths) || !Array.isArray(snapshot.boxes) || !Array.isArray(snapshot.memos)) {
|
|
111
|
+
const err = new Error("Invalid canvas annotation snapshot");
|
|
112
|
+
err.status = 400;
|
|
113
|
+
err.code = "INVALID_CANVAS_ANNOTATION_SNAPSHOT";
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
104
117
|
export async function createCanvasVersion(ctx, input) {
|
|
105
118
|
assertPngBuffer(input.buffer);
|
|
106
119
|
const sourceFilename = basename(String(input.sourceFilename || ""));
|
|
@@ -126,6 +139,9 @@ export async function createCanvasVersion(ctx, input) {
|
|
|
126
139
|
canvasVersion: true,
|
|
127
140
|
canvasSourceFilename: sourceFilename,
|
|
128
141
|
canvasEditableFilename: filename,
|
|
142
|
+
annotationsBaked: false,
|
|
143
|
+
annotationSnapshot: null,
|
|
144
|
+
annotationOnly: false,
|
|
129
145
|
};
|
|
130
146
|
await writeCanvasPng(ctx, filename, input.buffer, meta);
|
|
131
147
|
return toGenerateItem(filename, meta);
|
|
@@ -159,7 +175,77 @@ export async function updateCanvasVersion(ctx, filename, input) {
|
|
|
159
175
|
canvasVersion: true,
|
|
160
176
|
canvasSourceFilename: sourceFilename,
|
|
161
177
|
canvasEditableFilename: filename,
|
|
178
|
+
annotationsBaked: Boolean(previousMeta?.annotationsBaked),
|
|
179
|
+
annotationSnapshot: previousMeta?.annotationSnapshot ?? null,
|
|
180
|
+
annotationOnly: Boolean(previousMeta?.annotationOnly) && !input.pixelEdited,
|
|
162
181
|
};
|
|
163
182
|
await writeCanvasPng(ctx, filename, input.buffer, meta);
|
|
164
183
|
return toGenerateItem(filename, meta);
|
|
165
184
|
}
|
|
185
|
+
export async function recordCanvasAnnotationBake(ctx, filename, snapshot, annotationOnly) {
|
|
186
|
+
assertSafeFilename(filename);
|
|
187
|
+
assertAnnotationSnapshot(snapshot);
|
|
188
|
+
const full = ensureInsideGeneratedDir(ctx.config.storage.generatedDir, filename);
|
|
189
|
+
const previousMeta = await readGeneratedMetadata(ctx, filename);
|
|
190
|
+
if (!previousMeta) {
|
|
191
|
+
const err = new Error("Canvas version not found");
|
|
192
|
+
err.status = 404;
|
|
193
|
+
err.code = "CANVAS_VERSION_NOT_FOUND";
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
const buffer = await readFile(full);
|
|
197
|
+
const meta = {
|
|
198
|
+
...previousMeta,
|
|
199
|
+
kind: "edit",
|
|
200
|
+
provider: "canvas",
|
|
201
|
+
format: "png",
|
|
202
|
+
prompt: previousMeta.prompt,
|
|
203
|
+
userPrompt: previousMeta.userPrompt,
|
|
204
|
+
promptMode: previousMeta.promptMode || "direct",
|
|
205
|
+
createdAt: previousMeta.createdAt || Date.now(),
|
|
206
|
+
canvasMergedAt: previousMeta.canvasMergedAt || Date.now(),
|
|
207
|
+
canvasVersion: true,
|
|
208
|
+
canvasSourceFilename: previousMeta.canvasSourceFilename || null,
|
|
209
|
+
canvasEditableFilename: filename,
|
|
210
|
+
annotationsBaked: true,
|
|
211
|
+
annotationSnapshot: snapshot,
|
|
212
|
+
annotationOnly,
|
|
213
|
+
};
|
|
214
|
+
await writeCanvasPng(ctx, filename, buffer, meta);
|
|
215
|
+
return toGenerateItem(filename, meta);
|
|
216
|
+
}
|
|
217
|
+
export async function revertCanvasAnnotations(ctx, filename) {
|
|
218
|
+
assertSafeFilename(filename);
|
|
219
|
+
const previousMeta = await readGeneratedMetadata(ctx, filename);
|
|
220
|
+
const sourceFilename = previousMeta?.canvasSourceFilename;
|
|
221
|
+
if (!previousMeta?.annotationsBaked || !sourceFilename) {
|
|
222
|
+
const err = new Error("Canvas version has no baked annotations");
|
|
223
|
+
err.status = 409;
|
|
224
|
+
err.code = "CANVAS_ANNOTATIONS_NOT_BAKED";
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
const sourceFull = ensureInsideGeneratedDir(ctx.config.storage.generatedDir, basename(sourceFilename));
|
|
228
|
+
const cleanPng = await sharp(await readFile(sourceFull)).png().toBuffer();
|
|
229
|
+
const snapshot = previousMeta.annotationSnapshot ?? null;
|
|
230
|
+
const annotationOnly = Boolean(previousMeta.annotationOnly);
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
const meta = {
|
|
233
|
+
...previousMeta,
|
|
234
|
+
kind: "edit",
|
|
235
|
+
provider: "canvas",
|
|
236
|
+
format: "png",
|
|
237
|
+
prompt: previousMeta.prompt,
|
|
238
|
+
userPrompt: previousMeta.userPrompt,
|
|
239
|
+
promptMode: previousMeta.promptMode || "direct",
|
|
240
|
+
createdAt: now,
|
|
241
|
+
canvasMergedAt: now,
|
|
242
|
+
canvasVersion: true,
|
|
243
|
+
canvasSourceFilename: sourceFilename,
|
|
244
|
+
canvasEditableFilename: filename,
|
|
245
|
+
annotationsBaked: false,
|
|
246
|
+
annotationSnapshot: null,
|
|
247
|
+
annotationOnly: false,
|
|
248
|
+
};
|
|
249
|
+
await writeCanvasPng(ctx, filename, cleanPng, meta);
|
|
250
|
+
return { item: toGenerateItem(filename, meta), snapshot, annotationOnly };
|
|
251
|
+
}
|
package/lib/cardNewsGenerator.js
CHANGED
|
@@ -7,6 +7,39 @@ import { writeCardNewsManifest, writeCardSidecar } from "./cardNewsManifestStore
|
|
|
7
7
|
import { invalidateHistoryIndex } from "./historyIndex.js";
|
|
8
8
|
import { requireRuntimeContext } from "./runtimeContext.js";
|
|
9
9
|
import { errInfo } from "./errInfo.js";
|
|
10
|
+
import { assertSafeSetId, resolveCardNewsSetDir } from "./cardNewsPath.js";
|
|
11
|
+
const MAX_CARDS = 30;
|
|
12
|
+
const MAX_CONCURRENCY = 4;
|
|
13
|
+
const MAX_TEXT_CHARS = 20_000;
|
|
14
|
+
const MAX_REFERENCES_PER_CARD = 8;
|
|
15
|
+
const MAX_REFERENCE_CHARS = 10 * 1024 * 1024;
|
|
16
|
+
function inputError(message, code) {
|
|
17
|
+
throw Object.assign(new Error(message), { status: 400, code });
|
|
18
|
+
}
|
|
19
|
+
function validateInput(input, cards) {
|
|
20
|
+
if (cards.length > MAX_CARDS)
|
|
21
|
+
inputError(`cards must contain at most ${MAX_CARDS} items`, "CARD_NEWS_TOO_MANY_CARDS");
|
|
22
|
+
const concurrency = input.concurrency === undefined ? 2 : Number(input.concurrency);
|
|
23
|
+
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) {
|
|
24
|
+
inputError(`concurrency must be an integer between 1 and ${MAX_CONCURRENCY}`, "CARD_NEWS_INVALID_CONCURRENCY");
|
|
25
|
+
}
|
|
26
|
+
for (const card of cards) {
|
|
27
|
+
const text = [card.headline, card.body, card.visualPrompt, ...(Array.isArray(card.textFields) ? card.textFields.map((f) => f?.text) : [])]
|
|
28
|
+
.filter((value) => typeof value === "string").join("");
|
|
29
|
+
if (text.length > MAX_TEXT_CHARS)
|
|
30
|
+
inputError(`card text must not exceed ${MAX_TEXT_CHARS} characters`, "CARD_NEWS_TEXT_TOO_LARGE");
|
|
31
|
+
const refs = Array.isArray(card.references) ? card.references : [];
|
|
32
|
+
if (refs.length > MAX_REFERENCES_PER_CARD || refs.some((ref) => typeof ref !== "string" || ref.length > MAX_REFERENCE_CHARS)) {
|
|
33
|
+
inputError("card references exceed the allowed size", "CARD_NEWS_REFERENCES_TOO_LARGE");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return concurrency;
|
|
37
|
+
}
|
|
38
|
+
export function validateCardNewsInput(input) {
|
|
39
|
+
if (input.setId !== undefined)
|
|
40
|
+
assertSafeSetId(input.setId);
|
|
41
|
+
validateInput(input, Array.isArray(input.cards) ? input.cards : []);
|
|
42
|
+
}
|
|
10
43
|
function formatRenderedTextInstruction(textFields = []) {
|
|
11
44
|
const visible = (Array.isArray(textFields) ? textFields : [])
|
|
12
45
|
.filter((field) => field?.renderMode === "in-image" && field.text);
|
|
@@ -50,8 +83,9 @@ async function mapLimit(items, limit, fn) {
|
|
|
50
83
|
}
|
|
51
84
|
export async function generateCardNewsSet(ctxIn, input, options = {}) {
|
|
52
85
|
const ctx = requireRuntimeContext(ctxIn);
|
|
53
|
-
const setId = input.setId || `cs_${ulid()}
|
|
86
|
+
const setId = assertSafeSetId(input.setId || `cs_${ulid()}`);
|
|
54
87
|
const cards = Array.isArray(input.cards) ? input.cards : [];
|
|
88
|
+
const concurrency = validateInput(input, cards);
|
|
55
89
|
const cardsToGenerate = cards.filter((card) => !card.locked);
|
|
56
90
|
if (cardsToGenerate.length === 0) {
|
|
57
91
|
const err = new Error("cards are required");
|
|
@@ -61,14 +95,14 @@ export async function generateCardNewsSet(ctxIn, input, options = {}) {
|
|
|
61
95
|
}
|
|
62
96
|
const imageTemplateId = input.imageTemplateId || "academy-lesson-square";
|
|
63
97
|
const { template, templateBase, b64: templateB64 } = await readTemplateBaseB64(ctx, imageTemplateId);
|
|
64
|
-
const dir =
|
|
98
|
+
const dir = resolveCardNewsSetDir(ctx.config.storage.generatedDir, setId);
|
|
65
99
|
await mkdir(dir, { recursive: true });
|
|
66
100
|
const quality = input.quality || "medium";
|
|
67
101
|
const size = input.size || template.size || "2048x2048";
|
|
68
102
|
const moderation = input.moderation || "low";
|
|
69
103
|
const model = input.model || ctx.config.imageModels.default;
|
|
70
104
|
const generateFn = options.generateFn || generateViaOAuth;
|
|
71
|
-
const generatedCards = await mapLimit(cardsToGenerate,
|
|
105
|
+
const generatedCards = await mapLimit(cardsToGenerate, concurrency, async (card, index) => {
|
|
72
106
|
const cardOrder = Number(card.cardOrder || card.order || index + 1);
|
|
73
107
|
const baseFilename = `card-${String(cardOrder).padStart(2, "0")}`;
|
|
74
108
|
const imageFilename = `${baseFilename}.png`;
|
package/lib/cardNewsJobStore.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { ulid } from "ulid";
|
|
2
|
+
import { assertSafeSetId } from "./cardNewsPath.js";
|
|
2
3
|
const jobs = new Map();
|
|
3
4
|
const TTL_MS = 30 * 60 * 1000;
|
|
5
|
+
const MAX_ACTIVE_JOBS = 3;
|
|
4
6
|
function summarize(job) {
|
|
5
7
|
const generated = job.cards.filter((card) => card.status === "generated").length;
|
|
6
8
|
const errors = job.cards.filter((card) => card.status === "error").length;
|
|
@@ -28,6 +30,14 @@ function statusFromCards(cards) {
|
|
|
28
30
|
return "done";
|
|
29
31
|
}
|
|
30
32
|
export function createCardNewsJob(plan) {
|
|
33
|
+
assertSafeSetId(plan.setId);
|
|
34
|
+
const activeJobs = [...jobs.values()].filter((job) => job.status === "queued" || job.status === "running").length;
|
|
35
|
+
if (activeJobs >= MAX_ACTIVE_JOBS) {
|
|
36
|
+
throw Object.assign(new Error(`At most ${MAX_ACTIVE_JOBS} Card News jobs may be active`), {
|
|
37
|
+
status: 400,
|
|
38
|
+
code: "CARD_NEWS_ACTIVE_JOB_LIMIT",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
31
41
|
const now = Date.now();
|
|
32
42
|
const job = {
|
|
33
43
|
jobId: `cj_${ulid()}`,
|
|
@@ -2,8 +2,9 @@ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { requireRuntimeContext } from "./runtimeContext.js";
|
|
4
4
|
import { errInfo } from "./errInfo.js";
|
|
5
|
+
import { assertSafeSetId, resolveCardNewsSetDir } from "./cardNewsPath.js";
|
|
5
6
|
export async function writeCardNewsManifest(generatedDir, manifest) {
|
|
6
|
-
const dir =
|
|
7
|
+
const dir = resolveCardNewsSetDir(generatedDir, manifest.setId);
|
|
7
8
|
await mkdir(dir, { recursive: true });
|
|
8
9
|
await writeFile(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
9
10
|
return { dir, manifestFilename: "manifest.json" };
|
|
@@ -16,14 +17,6 @@ function cardUrl(setId, imageFilename) {
|
|
|
16
17
|
return undefined;
|
|
17
18
|
return `/generated/cardnews/${encodeURIComponent(setId)}/${encodeURIComponent(imageFilename)}`;
|
|
18
19
|
}
|
|
19
|
-
function assertSafeSetId(setId) {
|
|
20
|
-
if (typeof setId === "string" && /^[a-zA-Z0-9_-]{3,120}$/.test(setId))
|
|
21
|
-
return setId;
|
|
22
|
-
const err = new Error("Card News set not found");
|
|
23
|
-
err.status = 404;
|
|
24
|
-
err.code = "CARD_NEWS_SET_NOT_FOUND";
|
|
25
|
-
throw err;
|
|
26
|
-
}
|
|
27
20
|
function manifestToPlan(manifest) {
|
|
28
21
|
return {
|
|
29
22
|
setId: manifest.setId,
|
|
@@ -57,9 +50,9 @@ export async function readCardNewsSetPlan(ctxIn, setId) {
|
|
|
57
50
|
}
|
|
58
51
|
export async function readCardNewsManifest(ctxIn, setId) {
|
|
59
52
|
const ctx = requireRuntimeContext(ctxIn);
|
|
60
|
-
const safeSetId = assertSafeSetId(setId);
|
|
53
|
+
const safeSetId = assertSafeSetId(setId, 404);
|
|
61
54
|
try {
|
|
62
|
-
const raw = await readFile(join(ctx.config.storage.generatedDir,
|
|
55
|
+
const raw = await readFile(join(resolveCardNewsSetDir(ctx.config.storage.generatedDir, safeSetId, 404), "manifest.json"), "utf8");
|
|
63
56
|
return JSON.parse(raw);
|
|
64
57
|
}
|
|
65
58
|
catch (err) {
|
|
@@ -80,7 +73,8 @@ export async function listCardNewsSets(ctxIn) {
|
|
|
80
73
|
if (!entry.isDirectory())
|
|
81
74
|
continue;
|
|
82
75
|
try {
|
|
83
|
-
const
|
|
76
|
+
const safeSetId = assertSafeSetId(entry.name, 404);
|
|
77
|
+
const raw = await readFile(join(resolveCardNewsSetDir(ctx.config.storage.generatedDir, safeSetId, 404), "manifest.json"), "utf8");
|
|
84
78
|
const manifest = JSON.parse(raw);
|
|
85
79
|
const first = (manifest.cards || []).find((card) => card.imageFilename);
|
|
86
80
|
sets.push({
|