ima2-gen 3.13.1 → 3.14.0

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 (157) hide show
  1. package/.env.example +10 -0
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +15 -2
  4. package/bin/commands/capabilities.js +5 -3
  5. package/bin/commands/config.js +15 -4
  6. package/bin/commands/defaults.js +7 -5
  7. package/bin/commands/doctor.js +88 -39
  8. package/bin/commands/gen.js +21 -4
  9. package/bin/commands/models.js +2 -2
  10. package/bin/commands/ping.js +1 -1
  11. package/bin/commands/prompt.js +5 -3
  12. package/bin/commands/service.js +32 -4
  13. package/bin/commands/tools.js +19 -3
  14. package/bin/commands/upscale.js +18 -4
  15. package/bin/commands/video.js +37 -50
  16. package/bin/ima2.js +28 -9
  17. package/bin/lib/characterResolve.js +2 -1
  18. package/bin/lib/client.js +155 -20
  19. package/bin/lib/config-store.js +40 -7
  20. package/bin/lib/doctor-bundle.js +6 -2
  21. package/bin/lib/doctor-checks.js +13 -29
  22. package/bin/lib/doctor-media.js +3 -3
  23. package/bin/lib/doctor-providers.js +48 -35
  24. package/bin/lib/doctor-report.js +78 -0
  25. package/bin/lib/doctor-runtime.js +173 -0
  26. package/bin/lib/mcpJob.js +19 -7
  27. package/bin/lib/output.js +3 -0
  28. package/bin/lib/platform.js +12 -9
  29. package/bin/lib/recover-output.js +9 -2
  30. package/bin/lib/sse.js +9 -1
  31. package/bin/lib/videoMcp.js +16 -8
  32. package/config.js +41 -5
  33. package/docs/API.md +154 -16
  34. package/docs/CLI.md +65 -1
  35. package/docs/DOCKER.md +19 -4
  36. package/docs/DOCKER.zh-CN.md +11 -4
  37. package/docs/DOCKER.zh-TW.md +11 -4
  38. package/docs/FAQ.md +13 -0
  39. package/docs/NPX_QUICKSTART.md +6 -0
  40. package/docs/README.ja.md +15 -2
  41. package/docs/README.ko.md +15 -2
  42. package/docs/README.zh-CN.md +15 -2
  43. package/docs/README.zh-TW.md +15 -2
  44. package/docs/migration/runtime-test-inventory.md +79 -8
  45. package/lib/agyArtifact.js +82 -0
  46. package/lib/agyArtifactRead.js +249 -0
  47. package/lib/agyImageAdapter.js +2 -329
  48. package/lib/agyProcess.js +122 -0
  49. package/lib/apiRequestBudget.js +48 -0
  50. package/lib/assetLifecycle.js +42 -9
  51. package/lib/canvasVersionStore.js +46 -14
  52. package/lib/capabilities.js +7 -1
  53. package/lib/configKeys.js +2 -0
  54. package/lib/errors/providerMap.js +26 -0
  55. package/lib/eventBus.js +10 -6
  56. package/lib/eventsPolicy.js +9 -0
  57. package/lib/geminiApiImageAdapter.js +1 -214
  58. package/lib/generatePipeline.js +39 -165
  59. package/lib/generatedMediaAccess.js +74 -0
  60. package/lib/generationErrors.js +3 -22
  61. package/lib/grokImageAdapter.js +2 -353
  62. package/lib/grokImageCore.js +1 -57
  63. package/lib/grokImageDownload.js +159 -0
  64. package/lib/grokImageDownloadPolicy.js +124 -0
  65. package/lib/grokImagePlanner.js +301 -0
  66. package/lib/grokMultimodeAdapter.js +1 -75
  67. package/lib/grokVideoDownload.js +137 -34
  68. package/lib/inflight.js +112 -112
  69. package/lib/jobs/terminalStore.js +45 -0
  70. package/lib/lanSessionStore.js +131 -0
  71. package/lib/localAccessPolicy.js +180 -0
  72. package/lib/localLanAccess.js +281 -0
  73. package/lib/logger.js +22 -13
  74. package/lib/mcp/downloadMediaResult.js +72 -78
  75. package/lib/multimodePipeline.js +39 -126
  76. package/lib/nodeGeneration.js +44 -101
  77. package/lib/nodeStore.js +25 -11
  78. package/lib/nodeValidation.js +5 -1
  79. package/lib/pinnedHttpGet.js +174 -0
  80. package/lib/promptImport/githubFolder.js +32 -18
  81. package/lib/promptImport/githubSource.js +14 -10
  82. package/lib/promptImport/parsePromptCandidates.js +32 -2
  83. package/lib/providers/adapters/agyOperations.js +179 -0
  84. package/lib/providers/adapters/comfy.js +1 -0
  85. package/lib/providers/adapters/geminiOperations.js +214 -0
  86. package/lib/providers/adapters/googleExecution.js +97 -0
  87. package/lib/providers/adapters/grokExecution.js +137 -0
  88. package/lib/providers/adapters/grokMultimodeOperations.js +83 -0
  89. package/lib/providers/adapters/grokOperations.js +63 -0
  90. package/lib/providers/adapters/openaiExecution.js +118 -0
  91. package/lib/providers/adapters/openaiOperations.js +201 -0
  92. package/lib/providers/adapters/openaiTypes.js +1 -0
  93. package/lib/providers/derive.js +4 -0
  94. package/lib/providers/deriveCore.js +2 -7
  95. package/lib/providers/execution/admission.js +25 -0
  96. package/lib/providers/execution/index.js +34 -0
  97. package/lib/providers/execution/legacy.js +17 -0
  98. package/lib/providers/execution/legacyClassic.js +66 -0
  99. package/lib/providers/execution/legacyEdit.js +30 -0
  100. package/lib/providers/execution/legacyMultimode.js +34 -0
  101. package/lib/providers/execution/legacyNode.js +41 -0
  102. package/lib/providers/execution/types.js +1 -0
  103. package/lib/providers/registry.js +19 -14
  104. package/lib/providers/surfaceSupport.js +22 -0
  105. package/lib/responsesImageAdapter.js +1 -389
  106. package/lib/responsesTransport.js +190 -0
  107. package/lib/spriteJobEvents.js +20 -2
  108. package/lib/ssePublish.js +5 -4
  109. package/lib/videoExtendI2vOperation.js +76 -0
  110. package/package.json +5 -2
  111. package/routes/edit.js +31 -123
  112. package/routes/events.js +140 -63
  113. package/routes/mcpMedia.js +10 -2
  114. package/routes/models.js +28 -25
  115. package/routes/videoExtended.js +9 -68
  116. package/server.js +24 -46
  117. package/ui/dist/.vite/manifest.json +90 -63
  118. package/ui/dist/assets/{AgentWorkspace-PbBytTeV.js → AgentWorkspace-CL3MLv3l.js} +2 -2
  119. package/ui/dist/assets/App-B9cEM6dJ.js +9 -0
  120. package/ui/dist/assets/AssetGenWorkspace-swWPvXrx.js +2 -0
  121. package/ui/dist/assets/AssetsWorkspace-CRVeYU2F.js +1 -0
  122. package/ui/dist/assets/{CardNewsWorkspace-BS3jKOja.js → CardNewsWorkspace-BAOxWLKf.js} +2 -2
  123. package/ui/dist/assets/GenerationRequestLogPanel-DZtYZm-D.js +1 -0
  124. package/ui/dist/assets/HomeWorkspace-BeZ1s0Jm.js +1 -0
  125. package/ui/dist/assets/KeyingPanel-ZyBSXioh.js +1 -0
  126. package/ui/dist/assets/NodeCanvas-BvvcCErf.js +7 -0
  127. package/ui/dist/assets/{PromptBuilderPanel-D97A4pwB.js → PromptBuilderPanel-DBycmShA.js} +2 -2
  128. package/ui/dist/assets/PromptImportDialog-ChvMv7ek.js +2 -0
  129. package/ui/dist/assets/PromptImportDiscoverySection-60py7whH.js +1 -0
  130. package/ui/dist/assets/PromptImportFolderSection-CCRh_-_7.js +1 -0
  131. package/ui/dist/assets/PromptLibraryPanel-BFWQO8OT.js +2 -0
  132. package/ui/dist/assets/SettingsWorkspace-B5Ghsgdj.js +1 -0
  133. package/ui/dist/assets/SpriteRecipeWorkspace-DRWdkmwU.js +1 -0
  134. package/ui/dist/assets/index-7ngEOVxt.js +5 -0
  135. package/ui/dist/assets/index-B5jy7ePg.css +1 -0
  136. package/ui/dist/assets/index-gxh_02DQ.js +22 -0
  137. package/ui/dist/assets/{pptxgen.es-D4pedbwM.js → pptxgen.es-OUNhQLEV.js} +1 -1
  138. package/ui/dist/assets/promptBuilderStore-BTUUzbBT.js +1 -0
  139. package/ui/dist/assets/useAgentDialogFocus-DSc2qY8p.js +1 -0
  140. package/ui/dist/index.html +3 -2
  141. package/ui/dist/assets/AssetGenWorkspace-B-TGlvVb.js +0 -2
  142. package/ui/dist/assets/AssetsWorkspace-LnGaW151.js +0 -1
  143. package/ui/dist/assets/GenerationRequestLogPanel-DuDWD_ic.js +0 -1
  144. package/ui/dist/assets/HomeWorkspace-DVP7UG6O.js +0 -1
  145. package/ui/dist/assets/KeyingPanel-5bRUFQaq.js +0 -1
  146. package/ui/dist/assets/NodeCanvas-BBEpERTb.js +0 -7
  147. package/ui/dist/assets/PromptImportDialog-C89MN5JY.js +0 -2
  148. package/ui/dist/assets/PromptImportDiscoverySection-BgRfDOs2.js +0 -1
  149. package/ui/dist/assets/PromptImportFolderSection-zTMyriT1.js +0 -1
  150. package/ui/dist/assets/PromptLibraryPanel-BD96qZu_.js +0 -2
  151. package/ui/dist/assets/SettingsWorkspace-BAOk3X_8.js +0 -1
  152. package/ui/dist/assets/SpriteRecipeWorkspace-ClIM5QRo.js +0 -1
  153. package/ui/dist/assets/index-0fpfY8vu.css +0 -1
  154. package/ui/dist/assets/index-BFciPGTn.js +0 -5
  155. package/ui/dist/assets/index-BbykeXR9.js +0 -30
  156. package/ui/dist/assets/promptBuilderStore-Do7M8g7J.js +0 -1
  157. package/ui/dist/assets/useAgentDialogFocus-B6-QgEVI.js +0 -1
@@ -14,47 +14,150 @@ function withTimeoutSignal(signal, timeoutMs) {
14
14
  export function isMp4Container(buffer) {
15
15
  return buffer.length >= 12 && buffer.subarray(4, 8).toString("ascii") === "ftyp";
16
16
  }
17
- export async function downloadVideo(ctx, url, signal) {
18
- const { combinedSignal, timer } = withTimeoutSignal(signal, downloadTimeoutMs(ctx));
17
+ function videoDownloadFailure(message) {
18
+ return grokError(message, 502, "GROK_VIDEO_DOWNLOAD_FAILED");
19
+ }
20
+ function assertVideoDownloadUrl(url) {
21
+ const parsed = new URL(url);
22
+ const isLoopback = ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname);
23
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback)) {
24
+ throw videoDownloadFailure("Grok video download URL must be HTTPS");
25
+ }
26
+ }
27
+ function assertDeclaredVideoLength(res, maxBytes) {
28
+ const contentLength = Number(res.headers.get("content-length") || "0");
29
+ if (contentLength > maxBytes) {
30
+ throw videoDownloadFailure("Grok video download exceeds the 100MB limit");
31
+ }
32
+ }
33
+ function videoDownloadContentType(res) {
34
+ if (!res.ok)
35
+ throw videoDownloadFailure(`Grok video download failed: HTTP ${res.status}`);
36
+ assertDeclaredVideoLength(res, MAX_VIDEO_DOWNLOAD_BYTES);
37
+ const contentType = res.headers.get("content-type") || "video/mp4";
38
+ if (!/^video\/mp4\b/i.test(contentType) && !/^application\/octet-stream\b/i.test(contentType)) {
39
+ throw videoDownloadFailure("Grok video download returned a non-video response");
40
+ }
41
+ return contentType;
42
+ }
43
+ function cancelVideoBodyBestEffort(target) {
19
44
  try {
20
- const parsed = new URL(url);
21
- const isLoopback = ["localhost", "127.0.0.1", "::1"].includes(parsed.hostname);
22
- if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && isLoopback)) {
23
- throw grokError("Grok video download URL must be HTTPS", 502, "GROK_VIDEO_DOWNLOAD_FAILED");
24
- }
25
- // Safe to replay: downloading a finished artifact creates nothing upstream.
26
- const res = await grokFetchWithRetry(() => fetch(url, { signal: combinedSignal }), { signal: combinedSignal, label: "video-download" });
27
- if (!res.ok)
28
- throw grokError(`Grok video download failed: HTTP ${res.status}`, 502, "GROK_VIDEO_DOWNLOAD_FAILED");
29
- const contentLength = Number(res.headers.get("content-length") || "0");
30
- if (contentLength > MAX_VIDEO_DOWNLOAD_BYTES) {
31
- throw grokError("Grok video download exceeds the 100MB limit", 502, "GROK_VIDEO_DOWNLOAD_FAILED");
45
+ const cancellation = target?.cancel("video download rejected");
46
+ if (cancellation)
47
+ void cancellation.catch(() => { });
48
+ }
49
+ catch {
50
+ // Cleanup must neither replace the original failure nor delay settlement.
51
+ }
52
+ }
53
+ async function readVideoChunk(reader, signal) {
54
+ signal.throwIfAborted();
55
+ let onAbort = () => { };
56
+ try {
57
+ const aborted = new Promise((_resolve, reject) => {
58
+ onAbort = () => reject(signal.reason);
59
+ signal.addEventListener("abort", onAbort, { once: true });
60
+ if (signal.aborted)
61
+ onAbort();
62
+ });
63
+ return await Promise.race([reader.read(), aborted]);
64
+ }
65
+ finally {
66
+ signal.removeEventListener("abort", onAbort);
67
+ }
68
+ }
69
+ /** Internal byte-limit seam; public download callers always use the fixed cap. */
70
+ export async function readVideoDownloadBody(res, signal, maxBytes = MAX_VIDEO_DOWNLOAD_BYTES) {
71
+ let reader;
72
+ const chunks = [];
73
+ const prefix = Buffer.alloc(12);
74
+ let total = 0;
75
+ let complete = false;
76
+ try {
77
+ signal.throwIfAborted();
78
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_VIDEO_DOWNLOAD_BYTES) {
79
+ throw new RangeError("Invalid internal video byte limit");
32
80
  }
33
- const contentType = res.headers.get("content-type") || "video/mp4";
34
- if (!/^video\/mp4\b/i.test(contentType) && !/^application\/octet-stream\b/i.test(contentType)) {
35
- throw grokError("Grok video download returned a non-video response", 502, "GROK_VIDEO_DOWNLOAD_FAILED");
81
+ assertDeclaredVideoLength(res, maxBytes);
82
+ if (!res.body)
83
+ throw videoDownloadFailure("Grok video download was empty");
84
+ reader = res.body.getReader();
85
+ while (true) {
86
+ const { done, value } = await readVideoChunk(reader, signal);
87
+ signal.throwIfAborted();
88
+ if (done)
89
+ break;
90
+ if (value.byteLength > maxBytes - total) {
91
+ throw videoDownloadFailure("Grok video download exceeds the 100MB limit");
92
+ }
93
+ if (value.byteLength === 0)
94
+ continue;
95
+ const chunk = Buffer.from(value);
96
+ if (total < 12)
97
+ chunk.copy(prefix, total, 0, Math.min(chunk.length, 12 - total));
98
+ total += chunk.byteLength;
99
+ chunks.push(chunk);
36
100
  }
37
- const buffer = Buffer.from(await res.arrayBuffer());
38
- clearTimeout(timer);
39
- if (buffer.length === 0)
40
- throw grokError("Grok video download was empty", 502, "GROK_VIDEO_DOWNLOAD_FAILED");
41
- if (buffer.length > MAX_VIDEO_DOWNLOAD_BYTES) {
42
- throw grokError("Grok video download exceeds the 100MB limit", 502, "GROK_VIDEO_DOWNLOAD_FAILED");
101
+ if (total === 0)
102
+ throw videoDownloadFailure("Grok video download was empty");
103
+ if (!isMp4Container(prefix.subarray(0, Math.min(total, 12)))) {
104
+ throw videoDownloadFailure("Grok video download returned an invalid MP4 container");
43
105
  }
44
- if (!isMp4Container(buffer)) {
45
- throw grokError("Grok video download returned an invalid MP4 container", 502, "GROK_VIDEO_DOWNLOAD_FAILED");
106
+ signal.throwIfAborted();
107
+ const buffer = Buffer.concat(chunks, total);
108
+ complete = true;
109
+ return buffer;
110
+ }
111
+ finally {
112
+ chunks.length = 0;
113
+ if (!complete)
114
+ cancelVideoBodyBestEffort(reader ?? res.body);
115
+ try {
116
+ reader?.releaseLock();
46
117
  }
118
+ catch { /* Preserve the original result. */ }
119
+ }
120
+ }
121
+ function mapVideoDownloadError(error, caller, combined) {
122
+ if (caller?.aborted)
123
+ return grokError("Generation canceled", 499, "GENERATION_CANCELED");
124
+ if (combined.aborted)
125
+ return grokError("Grok video download timed out", 504, "GROK_VIDEO_TIMEOUT");
126
+ const fields = error !== null && (typeof error === "object" || typeof error === "function")
127
+ ? error : undefined;
128
+ if (fields?.name === "AbortError") {
129
+ return grokError("Grok video download timed out", 504, "GROK_VIDEO_TIMEOUT");
130
+ }
131
+ if (fields?.code && fields.status)
132
+ return error;
133
+ if (fields?.name === "TimeoutError") {
134
+ return grokError("Grok video download timed out", 504, "GROK_VIDEO_TIMEOUT");
135
+ }
136
+ const detail = error instanceof Error && typeof error.message === "string" ? error.message : "Unknown error";
137
+ return videoDownloadFailure(`Grok video download request failed: ${detail}`);
138
+ }
139
+ export async function downloadVideo(ctx, url, signal) {
140
+ const { combinedSignal, timer } = withTimeoutSignal(signal, downloadTimeoutMs(ctx));
141
+ let response;
142
+ let readerOwnsBody = false;
143
+ try {
144
+ combinedSignal.throwIfAborted();
145
+ assertVideoDownloadUrl(url);
146
+ // Safe to replay: downloading a finished artifact creates nothing upstream.
147
+ response = await grokFetchWithRetry(() => fetch(url, { signal: combinedSignal }), { signal: combinedSignal, label: "video-download" });
148
+ combinedSignal.throwIfAborted();
149
+ const contentType = videoDownloadContentType(response);
150
+ readerOwnsBody = true;
151
+ const buffer = await readVideoDownloadBody(response, combinedSignal);
152
+ combinedSignal.throwIfAborted();
47
153
  return { buffer, contentType };
48
154
  }
49
- catch (e) {
155
+ catch (error) {
156
+ throw mapVideoDownloadError(error, signal, combinedSignal);
157
+ }
158
+ finally {
50
159
  clearTimeout(timer);
51
- if (e.name === "AbortError") {
52
- if (signal?.aborted)
53
- throw grokError("Generation canceled", 499, "GENERATION_CANCELED");
54
- throw grokError("Grok video download timed out", 504, "GROK_VIDEO_TIMEOUT");
55
- }
56
- if (e.code && e.status)
57
- throw e;
58
- throw grokError(`Grok video download request failed: ${e.message}`, 502, "GROK_VIDEO_DOWNLOAD_FAILED");
160
+ if (!readerOwnsBody)
161
+ cancelVideoBodyBestEffort(response?.body);
59
162
  }
60
163
  }
package/lib/inflight.js CHANGED
@@ -2,6 +2,7 @@ import { config } from "../config.js";
2
2
  import { getDb } from "./db.js";
3
3
  import { publish } from "./eventBus.js";
4
4
  import { buildEnvelope } from "./jobs/envelope.js";
5
+ import { deleteExpiredTerminalJobs, deleteTerminalJob, readTerminalJob, readTerminalJobs, writeTerminalJob } from "./jobs/terminalStore.js";
5
6
  import { logError, logEvent } from "./logger.js";
6
7
  const terminalJobs = new Map(); // requestId -> terminal snapshot, active-only API stays default
7
8
  /**
@@ -18,25 +19,9 @@ function ensureTerminalJobsRestored() {
18
19
  terminalJobsRestored = true;
19
20
  try {
20
21
  const cutoff = Date.now() - config.inflight.terminalTtlMs;
21
- const rows = getDb()
22
- .prepare("SELECT * FROM terminal_jobs WHERE finished_at > ?")
23
- .all(cutoff);
24
- for (const row of rows) {
25
- if (terminalJobs.has(row.request_id))
26
- continue;
27
- terminalJobs.set(row.request_id, {
28
- requestId: row.request_id,
29
- kind: row.kind,
30
- status: row.status,
31
- startedAt: Number(row.started_at),
32
- finishedAt: Number(row.finished_at),
33
- durationMs: Number(row.finished_at) - Number(row.started_at),
34
- phase: row.phase || "unknown",
35
- phaseAt: Number(row.phase_at || row.finished_at),
36
- httpStatus: row.http_status ?? undefined,
37
- errorCode: row.error_code ?? undefined,
38
- meta: parseMeta(row.meta),
39
- });
22
+ for (const job of readTerminalJobs(cutoff)) {
23
+ if (!terminalJobs.has(job.requestId))
24
+ terminalJobs.set(job.requestId, job);
40
25
  }
41
26
  }
42
27
  catch (err) {
@@ -47,13 +32,7 @@ function ensureTerminalJobsRestored() {
47
32
  }
48
33
  function persistTerminalJob(job) {
49
34
  try {
50
- getDb()
51
- .prepare(`
52
- INSERT OR REPLACE INTO terminal_jobs (
53
- request_id, kind, status, started_at, finished_at, phase, phase_at, http_status, error_code, meta
54
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
55
- `)
56
- .run(job.requestId, job.kind, job.status, job.startedAt, job.finishedAt, job.phase, job.phaseAt, job.httpStatus ?? null, job.errorCode ?? null, JSON.stringify(job.meta ?? {}));
35
+ writeTerminalJob(job);
57
36
  }
58
37
  catch (err) {
59
38
  logError("inflight", "terminal_persist:error", err);
@@ -73,13 +52,11 @@ export function startJob({ requestId, kind, prompt, meta = {}, respectCanceledTo
73
52
  if (!requestId)
74
53
  return;
75
54
  purgeStaleJobs();
55
+ ensureTerminalJobsRestored();
76
56
  if (getJob(requestId)) {
77
57
  return { ok: false, code: "REQUEST_ID_IN_USE" };
78
58
  }
79
- // Opt-in tombstone respect (extend audit B2 round 3): a DELETE that raced
80
- // ahead of admission must still win. Without this, startJob deleted the
81
- // tombstone and ran a job the user already canceled. Off by default — the
82
- // agent queue's retry path reuses requestIds after cancel legitimately.
59
+ // Extend respects a cancel that preceded admission; queue retry may reuse IDs.
83
60
  if (respectCanceledTombstone && terminalJobs.get(requestId)?.status === "canceled") {
84
61
  return { ok: false, code: "GENERATION_CANCELED" };
85
62
  }
@@ -90,23 +67,8 @@ export function startJob({ requestId, kind, prompt, meta = {}, respectCanceledTo
90
67
  const normalizedPrompt = typeof prompt === "string" ? prompt.slice(0, 500) : "";
91
68
  const normalizedMeta = normalizeMeta(meta);
92
69
  try {
93
- getDb()
94
- .prepare(`
95
- INSERT INTO inflight (
96
- request_id,
97
- kind,
98
- prompt,
99
- meta,
100
- session_id,
101
- parent_node_id,
102
- client_node_id,
103
- started_at,
104
- phase,
105
- phase_at
106
- )
107
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
108
- `)
109
- .run(requestId, kind, normalizedPrompt, JSON.stringify(normalizedMeta), stringOrNull(normalizedMeta.sessionId), stringOrNull(normalizedMeta.parentNodeId), stringOrNull(normalizedMeta.clientNodeId), startedAt, "queued", startedAt);
70
+ insertJob({ requestId, kind, prompt: normalizedPrompt, meta: normalizedMeta,
71
+ startedAt, phase: "queued", phaseAt: startedAt });
110
72
  }
111
73
  catch (err) {
112
74
  const code = err?.code;
@@ -131,55 +93,58 @@ export function startJob({ requestId, kind, prompt, meta = {}, respectCanceledTo
131
93
  export function registerJobAbortController(requestId, controller) {
132
94
  if (!requestId)
133
95
  return;
96
+ if (isJobCanceled(requestId) || isJobTrackingExpired(requestId)) {
97
+ controller.abort();
98
+ return;
99
+ }
134
100
  abortControllers.set(requestId, controller);
135
101
  controllerRegisteredAt.set(requestId, Date.now());
136
102
  }
137
103
  export function abortJob(requestId) {
138
104
  if (!requestId)
139
105
  return { requestId: "", active: false, aborted: false };
106
+ const alreadyTerminal = isJobCanceled(requestId) || isJobTrackingExpired(requestId);
140
107
  const controller = abortControllers.get(requestId);
141
108
  const active = Boolean(getJob(requestId));
142
- let aborted = false;
143
- if (controller && !controller.signal.aborted) {
144
- controller.abort();
145
- aborted = true;
109
+ const aborted = Boolean(controller && !controller.signal.aborted);
110
+ const inflightPhase = getJobPhase(requestId);
111
+ let failed;
112
+ try {
113
+ finishJob(requestId, { canceled: true, httpStatus: 499, errorCode: "GENERATION_CANCELED" });
146
114
  }
147
- if (active || aborted) {
148
- // #151 stage 2: the cancel path is a terminal event, so it carries the
149
- // canonical envelope. Assembled inline rather than via ssePublish because
150
- // ssePublish imports this module (cycle), and abortJob already knows the
151
- // inflight phase locally.
152
- const data = {
153
- error: "Generation canceled",
154
- code: "GENERATION_CANCELED",
155
- status: 499,
156
- requestId,
157
- };
158
- const inflightPhase = getJobPhase(requestId);
159
- publish(requestId, "error", data, {
160
- buildEnvelope: (sequence) => buildEnvelope({
161
- jobId: requestId,
162
- requestId,
163
- sequence,
164
- event: "error",
165
- data,
166
- inflightPhase,
167
- }),
168
- });
115
+ catch (error) {
116
+ failed = { error };
169
117
  }
170
- finishJob(requestId, {
171
- canceled: true,
172
- httpStatus: 499,
173
- errorCode: "GENERATION_CANCELED",
174
- });
118
+ // Establish terminal dominance before synchronous AbortSignal listeners run.
119
+ if (aborted)
120
+ controller.abort();
121
+ if (!alreadyTerminal && (active || aborted)) {
122
+ publishTerminalError(requestId, inflightPhase, "GENERATION_CANCELED", 499, "Generation canceled");
123
+ }
124
+ if (failed)
125
+ throw failed.error;
175
126
  return { requestId, active, aborted };
176
127
  }
128
+ function publishTerminalError(requestId, inflightPhase, code, status, error) {
129
+ const data = { requestId, code, status, error };
130
+ // The registry cannot use ssePublish, which imports it. Keep this cycle-free.
131
+ publish(requestId, "error", data, {
132
+ buildEnvelope: (sequence) => buildEnvelope({ jobId: requestId, requestId,
133
+ sequence, event: "error", data, inflightPhase }),
134
+ });
135
+ }
177
136
  export function isJobCanceled(requestId) {
178
137
  if (!requestId)
179
138
  return false;
180
139
  ensureTerminalJobsRestored();
181
140
  return terminalJobs.get(requestId)?.status === "canceled";
182
141
  }
142
+ export function isJobTrackingExpired(requestId) {
143
+ if (!requestId)
144
+ return false;
145
+ ensureTerminalJobsRestored();
146
+ return terminalJobs.get(requestId)?.errorCode === "JOB_TRACKING_TIMEOUT";
147
+ }
183
148
  /**
184
149
  * Current raw phase for a job, or null when it is not active.
185
150
  *
@@ -227,39 +192,28 @@ export function updateJobAdmission(requestId, { prompt, meta }) {
227
192
  logError("inflight", "update_admission:error", err);
228
193
  }
229
194
  }
195
+ function recordFinishedJob(job, options, finishedAt) {
196
+ const status = options.canceled ? "canceled" : options.status || "completed";
197
+ const snapshot = {
198
+ requestId: job.requestId, kind: job.kind, status, startedAt: job.startedAt,
199
+ finishedAt, durationMs: finishedAt - job.startedAt, phase: job.phase, phaseAt: job.phaseAt,
200
+ httpStatus: options.httpStatus, errorCode: options.errorCode,
201
+ meta: { ...job.meta, ...(options.meta || {}) },
202
+ };
203
+ terminalJobs.set(job.requestId, snapshot);
204
+ persistTerminalJob(snapshot);
205
+ logEvent("inflight", "finish", { requestId: job.requestId, kind: job.kind, status,
206
+ durationMs: snapshot.durationMs, httpStatus: options.httpStatus, errorCode: options.errorCode });
207
+ }
230
208
  export function finishJob(requestId, options = {}) {
231
209
  if (!requestId)
232
210
  return;
211
+ ensureTerminalJobsRestored();
233
212
  const j = getJob(requestId);
234
213
  const finishedAt = Date.now();
235
- if (j) {
236
- const status = options.canceled ? "canceled" : options.status || "completed";
237
- const snapshot = {
238
- requestId,
239
- kind: j.kind,
240
- status,
241
- startedAt: j.startedAt,
242
- finishedAt,
243
- durationMs: finishedAt - j.startedAt,
244
- phase: j.phase,
245
- phaseAt: j.phaseAt,
246
- httpStatus: options.httpStatus,
247
- errorCode: options.errorCode,
248
- meta: {
249
- ...j.meta,
250
- ...(options.meta || {}),
251
- },
252
- };
253
- terminalJobs.set(requestId, snapshot);
254
- persistTerminalJob(snapshot);
255
- logEvent("inflight", "finish", {
256
- requestId,
257
- kind: j.kind,
258
- status,
259
- durationMs: finishedAt - j.startedAt,
260
- httpStatus: options.httpStatus,
261
- errorCode: options.errorCode,
262
- });
214
+ const forcedTerminal = isJobCanceled(requestId) || isJobTrackingExpired(requestId);
215
+ if (j && !forcedTerminal) {
216
+ recordFinishedJob(j, options, finishedAt);
263
217
  }
264
218
  else if (options.canceled && !terminalJobs.has(requestId)) {
265
219
  const tombstone = {
@@ -289,9 +243,7 @@ export function reapTerminalJobs(now = Date.now()) {
289
243
  terminalJobs.delete(id);
290
244
  }
291
245
  try {
292
- getDb()
293
- .prepare("DELETE FROM terminal_jobs WHERE finished_at <= ?")
294
- .run(now - config.inflight.terminalTtlMs);
246
+ deleteExpiredTerminalJobs(now - config.inflight.terminalTtlMs);
295
247
  }
296
248
  catch (err) {
297
249
  logError("inflight", "terminal_reap:error", err);
@@ -346,9 +298,57 @@ export function _resetForTests() {
346
298
  controllerRegisteredAt.clear();
347
299
  }
348
300
  export function purgeStaleJobs(now = Date.now()) {
349
- getDb()
350
- .prepare("DELETE FROM inflight WHERE started_at < ?")
351
- .run(now - config.inflight.ttlMs);
301
+ ensureTerminalJobsRestored();
302
+ let outcomes;
303
+ try {
304
+ outcomes = getDb().transaction(() => expireStaleRows(now))();
305
+ }
306
+ catch (error) {
307
+ logError("inflight", "expire:error", error);
308
+ return;
309
+ }
310
+ // Commit every memory outcome before any reentrant abort listener can publish.
311
+ for (const { terminal } of outcomes)
312
+ terminalJobs.set(terminal.requestId, terminal);
313
+ for (const { terminal, expired } of outcomes) {
314
+ const controller = abortControllers.get(terminal.requestId);
315
+ abortControllers.delete(terminal.requestId);
316
+ controllerRegisteredAt.delete(terminal.requestId);
317
+ if (terminal.status === "canceled" || terminal.errorCode === "JOB_TRACKING_TIMEOUT")
318
+ controller?.abort();
319
+ if (expired)
320
+ publishTerminalError(terminal.requestId, terminal.phase, "JOB_TRACKING_TIMEOUT", 504, "Job tracking expired; upstream completion is unknown. Inspect history before retrying.");
321
+ }
322
+ }
323
+ function expireStaleRows(now) {
324
+ const rows = getDb().prepare(`SELECT request_id, kind, prompt, meta, session_id,
325
+ parent_node_id, client_node_id, started_at, phase, phase_at FROM inflight WHERE started_at < ?`)
326
+ .all(now - config.inflight.ttlMs);
327
+ const cutoff = now - config.inflight.terminalTtlMs;
328
+ return rows.map(row => {
329
+ const memory = terminalJobs.get(row.request_id);
330
+ const disk = readTerminalJob(row.request_id, cutoff);
331
+ const retained = memory && memory.finishedAt > cutoff ? memory : disk;
332
+ const terminal = retained ?? expiredSnapshot(rowToJob(row), now);
333
+ // Also repairs a prior best-effort persistence failure without changing time/status.
334
+ if (!retained || JSON.stringify(terminal) !== JSON.stringify(disk))
335
+ writeTerminalJob(terminal);
336
+ getDb().prepare("DELETE FROM inflight WHERE request_id = ?").run(row.request_id);
337
+ return { terminal, expired: !retained };
338
+ });
339
+ }
340
+ function expiredSnapshot(job, now) {
341
+ return { requestId: job.requestId, kind: job.kind, status: "error", startedAt: job.startedAt,
342
+ finishedAt: now, durationMs: now - job.startedAt, phase: job.phase, phaseAt: job.phaseAt,
343
+ httpStatus: 504, errorCode: "JOB_TRACKING_TIMEOUT", meta: job.meta };
344
+ }
345
+ function insertJob(job) {
346
+ getDb().transaction(() => {
347
+ getDb().prepare(`INSERT INTO inflight (request_id, kind, prompt, meta, session_id,
348
+ parent_node_id, client_node_id, started_at, phase, phase_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
349
+ .run(job.requestId, job.kind, job.prompt, JSON.stringify(job.meta), stringOrNull(job.meta.sessionId), stringOrNull(job.meta.parentNodeId), stringOrNull(job.meta.clientNodeId), job.startedAt, job.phase, job.phaseAt);
350
+ deleteTerminalJob(job.requestId);
351
+ })();
352
352
  }
353
353
  function countActiveJobs() {
354
354
  const row = getDb()
@@ -0,0 +1,45 @@
1
+ import { getDb } from "../db.js";
2
+ const COLUMNS = "request_id, kind, status, started_at, finished_at, phase, phase_at, http_status, error_code, meta";
3
+ function parseMeta(raw) {
4
+ if (typeof raw !== "string" || !raw)
5
+ return {};
6
+ try {
7
+ const parsed = JSON.parse(raw);
8
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
9
+ ? parsed : {};
10
+ }
11
+ catch {
12
+ return {};
13
+ }
14
+ }
15
+ function fromRow(row) {
16
+ return {
17
+ requestId: row.request_id, kind: row.kind, status: row.status,
18
+ startedAt: Number(row.started_at), finishedAt: Number(row.finished_at),
19
+ durationMs: Number(row.finished_at) - Number(row.started_at),
20
+ phase: row.phase || "unknown", phaseAt: Number(row.phase_at || row.finished_at),
21
+ httpStatus: row.http_status ?? undefined, errorCode: row.error_code ?? undefined,
22
+ meta: parseMeta(row.meta),
23
+ };
24
+ }
25
+ export function readTerminalJobs(cutoff) {
26
+ const rows = getDb().prepare(`SELECT ${COLUMNS} FROM terminal_jobs WHERE finished_at > ?`)
27
+ .all(cutoff);
28
+ return rows.map(fromRow);
29
+ }
30
+ export function readTerminalJob(requestId, cutoff) {
31
+ const row = getDb().prepare(`SELECT ${COLUMNS} FROM terminal_jobs WHERE request_id = ? AND finished_at > ?`)
32
+ .get(requestId, cutoff);
33
+ return row ? fromRow(row) : null;
34
+ }
35
+ /** Throws on failure so the caller can include the write in its transaction. */
36
+ export function writeTerminalJob(job) {
37
+ getDb().prepare(`INSERT OR REPLACE INTO terminal_jobs (${COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
38
+ .run(job.requestId, job.kind, job.status, job.startedAt, job.finishedAt, job.phase, job.phaseAt, job.httpStatus ?? null, job.errorCode ?? null, JSON.stringify(job.meta ?? {}));
39
+ }
40
+ export function deleteTerminalJob(requestId) {
41
+ getDb().prepare("DELETE FROM terminal_jobs WHERE request_id = ?").run(requestId);
42
+ }
43
+ export function deleteExpiredTerminalJobs(cutoff) {
44
+ getDb().prepare("DELETE FROM terminal_jobs WHERE finished_at <= ?").run(cutoff);
45
+ }
@@ -0,0 +1,131 @@
1
+ import { createHash, randomBytes as cryptoRandomBytes } from "node:crypto";
2
+ import { localAccessError } from "./localAccessPolicy.js";
3
+ const ID_ATTEMPTS = 4;
4
+ const digest = (value) => createHash("sha256").update(value).digest("hex");
5
+ class LanSessionStore {
6
+ options;
7
+ now;
8
+ random;
9
+ records = new Map();
10
+ timer;
11
+ disposed = false;
12
+ constructor(options) {
13
+ this.options = options;
14
+ this.now = options.now ?? Date.now;
15
+ this.random = options.randomBytes ?? cryptoRandomBytes;
16
+ }
17
+ remove(key) {
18
+ const record = this.records.get(key);
19
+ this.records.delete(key);
20
+ if (!record)
21
+ return;
22
+ const closers = [...record.closers];
23
+ record.closers.clear();
24
+ for (const close of closers) {
25
+ try {
26
+ close();
27
+ }
28
+ catch { /* A broken response must not prevent revoking its siblings. */ }
29
+ }
30
+ }
31
+ schedule() {
32
+ clearTimeout(this.timer);
33
+ this.timer = undefined;
34
+ if (this.disposed || !this.records.size)
35
+ return;
36
+ const nearest = Math.min(...[...this.records.values()].map(record => record.expiresAt));
37
+ this.timer = setTimeout(() => { this.prune(); this.schedule(); }, Math.max(1, nearest - this.now()));
38
+ this.timer.unref();
39
+ }
40
+ prune() {
41
+ for (const [key, record] of this.records)
42
+ if (record.expiresAt <= this.now())
43
+ this.remove(key);
44
+ }
45
+ issue(origin) {
46
+ if (this.disposed)
47
+ throw localAccessError("LAN_SESSION_UNAVAILABLE", 503);
48
+ this.prune();
49
+ if (this.disposed)
50
+ throw localAccessError("LAN_SESSION_UNAVAILABLE", 503);
51
+ const { records, options, random, now } = this;
52
+ if (records.size >= options.maxSessions)
53
+ throw localAccessError("LAN_SESSION_CAPACITY", 503);
54
+ for (let attempt = 0; attempt < ID_ATTEMPTS; attempt++) {
55
+ let value;
56
+ try {
57
+ value = random(32).toString("base64url");
58
+ }
59
+ catch {
60
+ throw localAccessError("LAN_SESSION_UNAVAILABLE", 503);
61
+ }
62
+ const key = digest(value);
63
+ if (records.has(key))
64
+ continue;
65
+ const issuedAt = now(), expiresAt = issuedAt + options.ttlMs;
66
+ records.set(key, { origin, issuedAt, expiresAt, closers: new Set() });
67
+ this.schedule();
68
+ return { value, expiresAt };
69
+ }
70
+ throw localAccessError("LAN_SESSION_UNAVAILABLE", 503);
71
+ }
72
+ validate(value, origin) {
73
+ this.prune();
74
+ this.schedule();
75
+ const record = this.records.get(digest(value));
76
+ return record?.origin === origin ? { expiresAt: record.expiresAt } : null;
77
+ }
78
+ track(value, close) {
79
+ this.prune();
80
+ this.schedule();
81
+ const record = this.records.get(digest(value));
82
+ if (!record) {
83
+ close();
84
+ return () => { };
85
+ }
86
+ record.closers.add(close);
87
+ return () => { record.closers.delete(close); };
88
+ }
89
+ revoke(value) { this.remove(digest(value)); this.schedule(); }
90
+ dispose() {
91
+ this.disposed = true;
92
+ clearTimeout(this.timer);
93
+ this.timer = undefined;
94
+ for (const key of this.records.keys())
95
+ this.remove(key);
96
+ }
97
+ }
98
+ export function createLanSessionStore(options) {
99
+ return new LanSessionStore(options);
100
+ }
101
+ /** Socket-peer failure budget; independent of the app's API admission budget. */
102
+ export function createLanAuthThrottle(options) {
103
+ const now = options.now ?? Date.now;
104
+ const buckets = new Map();
105
+ function prune() {
106
+ for (const [peer, bucket] of buckets)
107
+ if (bucket.expiresAt <= now())
108
+ buckets.delete(peer);
109
+ }
110
+ return {
111
+ retryAfter(peer) {
112
+ prune();
113
+ const bucket = buckets.get(peer);
114
+ if (bucket && bucket.failures >= options.maxFailures)
115
+ return Math.max(1, Math.ceil((bucket.expiresAt - now()) / 1000));
116
+ if (!bucket && buckets.size >= options.maxBuckets) {
117
+ return Math.max(1, Math.ceil((Math.min(...[...buckets.values()].map(b => b.expiresAt)) - now()) / 1000));
118
+ }
119
+ return 0;
120
+ },
121
+ fail(peer) {
122
+ prune();
123
+ const bucket = buckets.get(peer);
124
+ if (bucket)
125
+ bucket.failures++;
126
+ else if (buckets.size < options.maxBuckets)
127
+ buckets.set(peer, { failures: 1, expiresAt: now() + options.windowMs });
128
+ },
129
+ dispose() { buckets.clear(); },
130
+ };
131
+ }