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.
- package/.env.example +10 -0
- package/CHANGELOG.md +14 -0
- package/README.md +15 -2
- package/bin/commands/capabilities.js +5 -3
- package/bin/commands/config.js +15 -4
- package/bin/commands/defaults.js +7 -5
- package/bin/commands/doctor.js +88 -39
- package/bin/commands/gen.js +21 -4
- package/bin/commands/models.js +2 -2
- package/bin/commands/ping.js +1 -1
- package/bin/commands/prompt.js +5 -3
- package/bin/commands/service.js +32 -4
- package/bin/commands/tools.js +19 -3
- package/bin/commands/upscale.js +18 -4
- package/bin/commands/video.js +37 -50
- package/bin/ima2.js +28 -9
- package/bin/lib/characterResolve.js +2 -1
- package/bin/lib/client.js +155 -20
- package/bin/lib/config-store.js +40 -7
- package/bin/lib/doctor-bundle.js +6 -2
- package/bin/lib/doctor-checks.js +13 -29
- package/bin/lib/doctor-media.js +3 -3
- package/bin/lib/doctor-providers.js +48 -35
- package/bin/lib/doctor-report.js +78 -0
- package/bin/lib/doctor-runtime.js +173 -0
- package/bin/lib/mcpJob.js +19 -7
- package/bin/lib/output.js +3 -0
- package/bin/lib/platform.js +12 -9
- package/bin/lib/recover-output.js +9 -2
- package/bin/lib/sse.js +9 -1
- package/bin/lib/videoMcp.js +16 -8
- package/config.js +41 -5
- package/docs/API.md +154 -16
- package/docs/CLI.md +65 -1
- package/docs/DOCKER.md +19 -4
- package/docs/DOCKER.zh-CN.md +11 -4
- package/docs/DOCKER.zh-TW.md +11 -4
- package/docs/FAQ.md +13 -0
- package/docs/NPX_QUICKSTART.md +6 -0
- package/docs/README.ja.md +15 -2
- package/docs/README.ko.md +15 -2
- package/docs/README.zh-CN.md +15 -2
- package/docs/README.zh-TW.md +15 -2
- package/docs/migration/runtime-test-inventory.md +79 -8
- package/lib/agyArtifact.js +82 -0
- package/lib/agyArtifactRead.js +249 -0
- package/lib/agyImageAdapter.js +2 -329
- package/lib/agyProcess.js +122 -0
- package/lib/apiRequestBudget.js +48 -0
- package/lib/assetLifecycle.js +42 -9
- package/lib/canvasVersionStore.js +46 -14
- package/lib/capabilities.js +7 -1
- package/lib/configKeys.js +2 -0
- package/lib/errors/providerMap.js +26 -0
- package/lib/eventBus.js +10 -6
- package/lib/eventsPolicy.js +9 -0
- package/lib/geminiApiImageAdapter.js +1 -214
- package/lib/generatePipeline.js +39 -165
- package/lib/generatedMediaAccess.js +74 -0
- package/lib/generationErrors.js +3 -22
- package/lib/grokImageAdapter.js +2 -353
- package/lib/grokImageCore.js +1 -57
- package/lib/grokImageDownload.js +159 -0
- package/lib/grokImageDownloadPolicy.js +124 -0
- package/lib/grokImagePlanner.js +301 -0
- package/lib/grokMultimodeAdapter.js +1 -75
- package/lib/grokVideoDownload.js +137 -34
- package/lib/inflight.js +112 -112
- package/lib/jobs/terminalStore.js +45 -0
- package/lib/lanSessionStore.js +131 -0
- package/lib/localAccessPolicy.js +180 -0
- package/lib/localLanAccess.js +281 -0
- package/lib/logger.js +22 -13
- package/lib/mcp/downloadMediaResult.js +72 -78
- package/lib/multimodePipeline.js +39 -126
- package/lib/nodeGeneration.js +44 -101
- package/lib/nodeStore.js +25 -11
- package/lib/nodeValidation.js +5 -1
- package/lib/pinnedHttpGet.js +174 -0
- package/lib/promptImport/githubFolder.js +32 -18
- package/lib/promptImport/githubSource.js +14 -10
- package/lib/promptImport/parsePromptCandidates.js +32 -2
- package/lib/providers/adapters/agyOperations.js +179 -0
- package/lib/providers/adapters/comfy.js +1 -0
- package/lib/providers/adapters/geminiOperations.js +214 -0
- package/lib/providers/adapters/googleExecution.js +97 -0
- package/lib/providers/adapters/grokExecution.js +137 -0
- package/lib/providers/adapters/grokMultimodeOperations.js +83 -0
- package/lib/providers/adapters/grokOperations.js +63 -0
- package/lib/providers/adapters/openaiExecution.js +118 -0
- package/lib/providers/adapters/openaiOperations.js +201 -0
- package/lib/providers/adapters/openaiTypes.js +1 -0
- package/lib/providers/derive.js +4 -0
- package/lib/providers/deriveCore.js +2 -7
- package/lib/providers/execution/admission.js +25 -0
- package/lib/providers/execution/index.js +34 -0
- package/lib/providers/execution/legacy.js +17 -0
- package/lib/providers/execution/legacyClassic.js +66 -0
- package/lib/providers/execution/legacyEdit.js +30 -0
- package/lib/providers/execution/legacyMultimode.js +34 -0
- package/lib/providers/execution/legacyNode.js +41 -0
- package/lib/providers/execution/types.js +1 -0
- package/lib/providers/registry.js +19 -14
- package/lib/providers/surfaceSupport.js +22 -0
- package/lib/responsesImageAdapter.js +1 -389
- package/lib/responsesTransport.js +190 -0
- package/lib/spriteJobEvents.js +20 -2
- package/lib/ssePublish.js +5 -4
- package/lib/videoExtendI2vOperation.js +76 -0
- package/package.json +5 -2
- package/routes/edit.js +31 -123
- package/routes/events.js +140 -63
- package/routes/mcpMedia.js +10 -2
- package/routes/models.js +28 -25
- package/routes/videoExtended.js +9 -68
- package/server.js +24 -46
- package/ui/dist/.vite/manifest.json +90 -63
- package/ui/dist/assets/{AgentWorkspace-PbBytTeV.js → AgentWorkspace-CL3MLv3l.js} +2 -2
- package/ui/dist/assets/App-B9cEM6dJ.js +9 -0
- package/ui/dist/assets/AssetGenWorkspace-swWPvXrx.js +2 -0
- package/ui/dist/assets/AssetsWorkspace-CRVeYU2F.js +1 -0
- package/ui/dist/assets/{CardNewsWorkspace-BS3jKOja.js → CardNewsWorkspace-BAOxWLKf.js} +2 -2
- package/ui/dist/assets/GenerationRequestLogPanel-DZtYZm-D.js +1 -0
- package/ui/dist/assets/HomeWorkspace-BeZ1s0Jm.js +1 -0
- package/ui/dist/assets/KeyingPanel-ZyBSXioh.js +1 -0
- package/ui/dist/assets/NodeCanvas-BvvcCErf.js +7 -0
- package/ui/dist/assets/{PromptBuilderPanel-D97A4pwB.js → PromptBuilderPanel-DBycmShA.js} +2 -2
- package/ui/dist/assets/PromptImportDialog-ChvMv7ek.js +2 -0
- package/ui/dist/assets/PromptImportDiscoverySection-60py7whH.js +1 -0
- package/ui/dist/assets/PromptImportFolderSection-CCRh_-_7.js +1 -0
- package/ui/dist/assets/PromptLibraryPanel-BFWQO8OT.js +2 -0
- package/ui/dist/assets/SettingsWorkspace-B5Ghsgdj.js +1 -0
- package/ui/dist/assets/SpriteRecipeWorkspace-DRWdkmwU.js +1 -0
- package/ui/dist/assets/index-7ngEOVxt.js +5 -0
- package/ui/dist/assets/index-B5jy7ePg.css +1 -0
- package/ui/dist/assets/index-gxh_02DQ.js +22 -0
- package/ui/dist/assets/{pptxgen.es-D4pedbwM.js → pptxgen.es-OUNhQLEV.js} +1 -1
- package/ui/dist/assets/promptBuilderStore-BTUUzbBT.js +1 -0
- package/ui/dist/assets/useAgentDialogFocus-DSc2qY8p.js +1 -0
- package/ui/dist/index.html +3 -2
- package/ui/dist/assets/AssetGenWorkspace-B-TGlvVb.js +0 -2
- package/ui/dist/assets/AssetsWorkspace-LnGaW151.js +0 -1
- package/ui/dist/assets/GenerationRequestLogPanel-DuDWD_ic.js +0 -1
- package/ui/dist/assets/HomeWorkspace-DVP7UG6O.js +0 -1
- package/ui/dist/assets/KeyingPanel-5bRUFQaq.js +0 -1
- package/ui/dist/assets/NodeCanvas-BBEpERTb.js +0 -7
- package/ui/dist/assets/PromptImportDialog-C89MN5JY.js +0 -2
- package/ui/dist/assets/PromptImportDiscoverySection-BgRfDOs2.js +0 -1
- package/ui/dist/assets/PromptImportFolderSection-zTMyriT1.js +0 -1
- package/ui/dist/assets/PromptLibraryPanel-BD96qZu_.js +0 -2
- package/ui/dist/assets/SettingsWorkspace-BAOk3X_8.js +0 -1
- package/ui/dist/assets/SpriteRecipeWorkspace-ClIM5QRo.js +0 -1
- package/ui/dist/assets/index-0fpfY8vu.css +0 -1
- package/ui/dist/assets/index-BFciPGTn.js +0 -5
- package/ui/dist/assets/index-BbykeXR9.js +0 -30
- package/ui/dist/assets/promptBuilderStore-Do7M8g7J.js +0 -1
- package/ui/dist/assets/useAgentDialogFocus-B6-QgEVI.js +0 -1
package/lib/grokVideoDownload.js
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
if (
|
|
35
|
-
throw
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
throw
|
|
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
|
-
|
|
45
|
-
|
|
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 (
|
|
155
|
+
catch (error) {
|
|
156
|
+
throw mapVideoDownloadError(error, signal, combinedSignal);
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
50
159
|
clearTimeout(timer);
|
|
51
|
-
if (
|
|
52
|
-
|
|
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
|
|
22
|
-
.
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
+
}
|