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.
Files changed (125) hide show
  1. package/README.md +30 -0
  2. package/bin/commands/skill.js +336 -17
  3. package/bin/ima2.js +22 -2
  4. package/bin/lib/doctor-checks.js +6 -4
  5. package/config.js +1 -0
  6. package/docs/CLI.md +25 -3
  7. package/docs/PROMPT_STUDIO.ko.md +29 -0
  8. package/docs/PROMPT_STUDIO.md +31 -0
  9. package/docs/migration/runtime-test-inventory.md +7 -1
  10. package/lib/agentImageVideoGen.js +8 -0
  11. package/lib/agentQueueStore.js +36 -10
  12. package/lib/agentQueueWorker.js +79 -7
  13. package/lib/agentRuntime.js +2 -0
  14. package/lib/assetLifecycle.js +40 -8
  15. package/lib/canvasVersionStore.js +86 -0
  16. package/lib/cardNewsGenerator.js +37 -3
  17. package/lib/cardNewsJobStore.js +10 -0
  18. package/lib/cardNewsManifestStore.js +6 -12
  19. package/lib/cardNewsPath.js +25 -0
  20. package/lib/db.js +2 -0
  21. package/lib/generatePipeline.js +474 -0
  22. package/lib/generationInputValidation.js +32 -0
  23. package/lib/grokImageCore.js +18 -1
  24. package/lib/historyIndex.js +11 -4
  25. package/lib/historyList.js +3 -0
  26. package/lib/imageMetadata.js +3 -0
  27. package/lib/inflight.js +30 -3
  28. package/lib/multimodePipeline.js +462 -0
  29. package/lib/nodeGeneration.js +440 -0
  30. package/lib/nodeValidation.js +23 -0
  31. package/lib/oauthProxy/generators.js +3 -246
  32. package/lib/oauthProxy/multimodeGenerators.js +251 -0
  33. package/lib/routeHelpers.js +9 -2
  34. package/package.json +2 -2
  35. package/routes/agent.js +4 -4
  36. package/routes/canvasVersions.js +21 -1
  37. package/routes/cardNews.js +2 -1
  38. package/routes/edit.js +25 -9
  39. package/routes/events.js +5 -1
  40. package/routes/generate.js +2 -481
  41. package/routes/keys.js +31 -39
  42. package/routes/multimode.js +2 -502
  43. package/routes/nodes.js +3 -448
  44. package/routes/video.js +12 -3
  45. package/routes/videoExtended.js +19 -4
  46. package/server.js +53 -1
  47. package/skills/ima2/SKILL.md +204 -0
  48. package/skills/ima2-front/SKILL.md +611 -0
  49. package/skills/ima2-front/references/a11y-patterns.md +134 -0
  50. package/skills/ima2-front/references/aesthetics.md +327 -0
  51. package/skills/ima2-front/references/anti-slop.md +241 -0
  52. package/skills/ima2-front/references/asset-requirements.md +431 -0
  53. package/skills/ima2-front/references/brand-asset-sourcing.md +122 -0
  54. package/skills/ima2-front/references/color-system.md +132 -0
  55. package/skills/ima2-front/references/consistency-locks.md +20 -0
  56. package/skills/ima2-front/references/crud-ui.md +53 -0
  57. package/skills/ima2-front/references/dropdown-layer.md +110 -0
  58. package/skills/ima2-front/references/i18n-global.md +104 -0
  59. package/skills/ima2-front/references/iterative-design.md +90 -0
  60. package/skills/ima2-front/references/korea-2026.md +174 -0
  61. package/skills/ima2-front/references/layout-discipline.md +161 -0
  62. package/skills/ima2-front/references/liquid-glass.md +181 -0
  63. package/skills/ima2-front/references/logo-sections.md +194 -0
  64. package/skills/ima2-front/references/mobile-ux.md +144 -0
  65. package/skills/ima2-front/references/performance-budget.md +106 -0
  66. package/skills/ima2-front/references/preflight-full.md +58 -0
  67. package/skills/ima2-front/references/product-density.md +53 -0
  68. package/skills/ima2-front/references/prototype-variants.md +54 -0
  69. package/skills/ima2-front/references/reference-capture.md +120 -0
  70. package/skills/ima2-front/references/responsive-viewport.md +139 -0
  71. package/skills/ima2-front/references/seo-baseline.md +123 -0
  72. package/skills/ima2-front/references/soft-3d-asset-gates.md +70 -0
  73. package/skills/ima2-front/references/stacks/astro.md +204 -0
  74. package/skills/ima2-front/references/stacks/mobile-native.md +281 -0
  75. package/skills/ima2-front/references/stacks/nextjs.md +219 -0
  76. package/skills/ima2-front/references/stacks/react.md +264 -0
  77. package/skills/ima2-front/references/stacks/svelte.md +235 -0
  78. package/skills/ima2-front/references/stacks/vanilla.md +226 -0
  79. package/skills/ima2-front/references/theme-switching.md +82 -0
  80. package/skills/ima2-front/references/top-bar.md +126 -0
  81. package/skills/ima2-front/references/typography-wrapping.md +270 -0
  82. package/skills/ima2-front/references/ux-writing-ko.md +62 -0
  83. package/skills/ima2-front/references/visual-verification.md +102 -0
  84. package/skills/ima2-uiux/SKILL.md +477 -0
  85. package/skills/ima2-uiux/references/anti-slop-judgment.md +39 -0
  86. package/skills/ima2-uiux/references/color-system.md +80 -0
  87. package/skills/ima2-uiux/references/design-isms.md +194 -0
  88. package/skills/ima2-uiux/references/design-read-example.md +33 -0
  89. package/skills/ima2-uiux/references/design-system-bootstrap.md +133 -0
  90. package/skills/ima2-uiux/references/favicon-logo.md +355 -0
  91. package/skills/ima2-uiux/references/form-patterns.md +118 -0
  92. package/skills/ima2-uiux/references/intent-discovery-ladder.md +86 -0
  93. package/skills/ima2-uiux/references/korean-design-vocabulary.md +52 -0
  94. package/skills/ima2-uiux/references/layout-macrostructures.md +54 -0
  95. package/skills/ima2-uiux/references/logo-trust-sections.md +96 -0
  96. package/skills/ima2-uiux/references/mobile-native-ux.md +207 -0
  97. package/skills/ima2-uiux/references/product-personalities.md +118 -0
  98. package/skills/ima2-uiux/references/responsive-nav.md +66 -0
  99. package/skills/ima2-uiux/references/typography-line-breaks.md +193 -0
  100. package/skills/ima2-uiux/references/ux-preflight.md +65 -0
  101. package/skills/ima2-uiux/references/ux-states.md +197 -0
  102. package/skills/ima2-uiux/references/visual-hierarchy.md +115 -0
  103. package/ui/dist/.vite/manifest.json +13 -13
  104. package/ui/dist/assets/AgentWorkspace-CtYt4SF5.js +3 -0
  105. package/ui/dist/assets/{CardNewsWorkspace-Cdxn1ohU.js → CardNewsWorkspace-DN5cVqG5.js} +1 -1
  106. package/ui/dist/assets/GenerationRequestLogPanel-DnmUQdKR.js +1 -0
  107. package/ui/dist/assets/{NodeCanvas-C67sLboA.js → NodeCanvas-CxpfIBI0.js} +1 -1
  108. package/ui/dist/assets/PromptBuilderPanel-C2R3ObtR.js +2 -0
  109. package/ui/dist/assets/{PromptImportDialog-DeQXsEEv.js → PromptImportDialog-BIxz_yEx.js} +2 -2
  110. package/ui/dist/assets/PromptImportDiscoverySection-BQTlPMME.js +1 -0
  111. package/ui/dist/assets/PromptImportFolderSection-Ds_Th9O3.js +1 -0
  112. package/ui/dist/assets/{PromptLibraryPanel-Db3xEPM_.js → PromptLibraryPanel-Rs4l45pV.js} +2 -2
  113. package/ui/dist/assets/{SettingsWorkspace-BDk_1W-V.js → SettingsWorkspace-p1AZ6uT9.js} +1 -1
  114. package/ui/dist/assets/index-0-_vgFGs.js +4 -0
  115. package/ui/dist/assets/index-Dm3pFxV4.js +23 -0
  116. package/ui/dist/assets/index-J8yDF3Ch.css +1 -0
  117. package/ui/dist/index.html +2 -2
  118. package/ui/dist/assets/AgentWorkspace-DAg_7zpY.js +0 -3
  119. package/ui/dist/assets/GenerationRequestLogPanel-C_qvEUi2.js +0 -1
  120. package/ui/dist/assets/PromptBuilderPanel-DjvVdAie.js +0 -2
  121. package/ui/dist/assets/PromptImportDiscoverySection-CrwyUKYI.js +0 -1
  122. package/ui/dist/assets/PromptImportFolderSection-CToGMqYE.js +0 -1
  123. package/ui/dist/assets/index-2oG6in1i.css +0 -1
  124. package/ui/dist/assets/index-CoxMOXAU.js +0 -23
  125. 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 = {
@@ -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
- WHERE id = ?
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 = 'queued'
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 failed = items.find((item) => item.status === "failed");
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,
@@ -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
- startJob({
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
- failAgentQueueItem(item.id, { code: err.code, message: err.message });
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: err.code ? `${err.message} [${err.code}]` : err.message,
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: err.code,
188
+ errorCode: failure.code,
135
189
  meta: { queueItemId: item.id },
136
190
  });
137
- logEvent("agent_queue", "error", { itemId: item.id, code: err.code, message: err.message });
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
  }
@@ -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);
@@ -1,5 +1,5 @@
1
1
  import { getDb } from "./db.js";
2
- import { mkdir, rename, unlink, access } from "fs/promises";
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 access(src);
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 access(sidecar);
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 access(src);
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
- await unlink(src + ".json").catch(() => { });
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
+ }
@@ -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 = join(ctx.config.storage.generatedDir, "cardnews", setId);
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, Number(input.concurrency) || 2, async (card, index) => {
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`;
@@ -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 = join(generatedDir, "cardnews", manifest.setId);
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, "cardnews", safeSetId, "manifest.json"), "utf8");
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 raw = await readFile(join(root, entry.name, "manifest.json"), "utf8");
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({