521 lines
19 KiB
TypeScript
521 lines
19 KiB
TypeScript
// SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-20-p2-terminal-outbox-recovery; PJ2026-010403 API契约 draft-2026-06-20-p2-terminal-outbox-recovery; PJ2026-010205 HWLAB接入 draft-2026-06-25-p0-session-warm-runner-contract; PJ2026-0102 Agent编排 draft-2026-06-17-r0; PJ2026-01060505 Workbench Performance draft-2026-06-17-p0.
|
|
// Responsibility: Code Agent HTTP steer and trace resources.
|
|
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
|
|
import { M3_IO_CONTROL_ROUTE, M3_STATUS_ROUTE, describeM3StatusLive, handleM3IoControl } from "./m3-io-control.ts";
|
|
import {
|
|
agentRunSessionEvidence,
|
|
cancelAgentRunChatTurn,
|
|
codeAgentAgentRunAdapterEnabled,
|
|
initialAgentRunChatResult,
|
|
loadPersistedAgentRunResult,
|
|
steerAgentRunChatTurn,
|
|
submitAgentRunChatTurn
|
|
} from "./code-agent-agentrun-adapter.ts";
|
|
import { createCodeAgentErrorPayload, handleCodeAgentChat } from "./code-agent-chat.ts";
|
|
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts";
|
|
import { codeAgentSessionLifecycleSummary } from "./code-agent-session-lifecycle.ts";
|
|
import { messageAuthorityTextValue } from "./code-agent-agentrun-prompt.ts";
|
|
import { createWorkbenchReadModel } from "./workbench-read-model.ts";
|
|
import { codeAgentOtelTraceFields, emitCodeAgentOtelSpan } from "./otel-trace.ts";
|
|
import {
|
|
firstHeaderValue,
|
|
getHeader,
|
|
parsePositiveInteger,
|
|
positiveInteger,
|
|
readBody,
|
|
safeConversationId,
|
|
safeOpaqueId,
|
|
safeSessionId,
|
|
safeTraceId,
|
|
sendJson,
|
|
truthyFlag
|
|
} from "./server-http-utils.ts";
|
|
|
|
import {
|
|
normalizeCodeAgentProviderProfile,
|
|
firstNonEmptyValue,
|
|
recordCodeAgentSessionInputFact,
|
|
stableCodeAgentInputId,
|
|
codeAgentInputDelivery,
|
|
codeAgentInputStatus,
|
|
codeAgentResultTimingFields,
|
|
firstTimestampIso,
|
|
firstLatestTimestampIso,
|
|
codeAgentChatShortConnectionRequested,
|
|
preflightCodeAgentBilling,
|
|
recordCodeAgentBillingUsage,
|
|
finalizeCodeAgentBillingUsage,
|
|
releaseCodeAgentBillingReservation,
|
|
withCodeAgentBillingReservation,
|
|
codeAgentBillingEnabled,
|
|
codeAgentBillingMetadata,
|
|
sanitizeBillingReservation,
|
|
sanitizeBillingRecord,
|
|
sanitizeBillingRelease,
|
|
codeAgentUsedTokens,
|
|
normalizeTurnStatus,
|
|
recordCodeAgentConversationFact,
|
|
recordCodeAgentSessionOwner,
|
|
codeAgentOwnerStatusForResult,
|
|
textValue,
|
|
timestampIsoOrNow,
|
|
timestampIsoOrNull,
|
|
codeAgentSessionOwnerEvidence,
|
|
codeAgentContinuationEvidence,
|
|
codeAgentFreshContinuationFailure,
|
|
normalizeCodeAgentFailureKind,
|
|
codeAgentFailureRequiresFreshContinuation,
|
|
codeAgentProviderProfileEvidence,
|
|
codeAgentTraceResultEvidence,
|
|
codeAgentPromptMetadata,
|
|
codeAgentPromptMetadataFromText,
|
|
codeAgentPromptMetadataFromParts,
|
|
codeAgentPromptFields,
|
|
codeAgentPromptId,
|
|
clippedPromptText,
|
|
sha256Text,
|
|
compactCodeAgentObject,
|
|
codeAgentBillingReservationEvidence,
|
|
codeAgentConversationMessagesEvidence,
|
|
boundedConversationMessageText,
|
|
conversationText,
|
|
conversationTextRaw,
|
|
codeAgentMessageTraceSuffix,
|
|
codeAgentTurnLifecycleFields,
|
|
codeAgentLifecycleMessageId,
|
|
safeTurnId,
|
|
safeMessageId,
|
|
codeAgentConversationAgentMessageStatus,
|
|
codeAgentPayloadHasSealedFinalResponse,
|
|
codeAgentConversationRunnerTraceEvidence,
|
|
codeAgentFinalResponseEvidence,
|
|
codeAgentFinalResponseText,
|
|
codeAgentTraceSummaryEvidence,
|
|
annotateOwner,
|
|
canAccessOwnedResult,
|
|
isCodeAgentResultCanceled,
|
|
codeAgentCancelScopeMismatch,
|
|
cancelExpectedValues,
|
|
cancelScopeFieldMismatch,
|
|
cancelBlockedPayload,
|
|
isTraceCommandTerminalStatus,
|
|
traceSnapshotWithTerminalEvidence,
|
|
traceSnapshotWithAttachedTerminalEvidence,
|
|
agentRunTerminalTraceEvidence,
|
|
agentRunTerminalFinalResponse,
|
|
traceRetentionSummary,
|
|
numberOrNull,
|
|
createCodeAgentChatResultStore,
|
|
compactCodeAgentChatResultPayload,
|
|
terminalEvidencePayload,
|
|
compactRunnerTraceForResult,
|
|
traceEventLabel,
|
|
resultTraceEventLimit,
|
|
createCodeAgentM3HwlabApiRequestJson,
|
|
recordCodeAgentProjectionMetrics,
|
|
recordCodeAgentTurnReadMetric,
|
|
turnReadDegradedReason,
|
|
responseBodyBytes,
|
|
statusClassLabel,
|
|
sourceLatestSeqFromResult,
|
|
sourceLatestAtFromResult,
|
|
terminalSourceAtFromResult,
|
|
projectionReason,
|
|
nowMs,
|
|
uniqueStrings
|
|
} from "./server-code-agent-http-support.ts";
|
|
|
|
import {
|
|
codeAgentCompatProjectionPayload,
|
|
codeAgentRequestScopedOptions,
|
|
measureCodeAgentHttpPhase,
|
|
readCodeAgentCompatProjection,
|
|
traceProjectMismatchSnapshot
|
|
} from "./server-code-agent-turn-http.ts";
|
|
|
|
const DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT = 120;
|
|
const DEFAULT_CODE_AGENT_TRACE_PAGE_LIMIT = 100;
|
|
const MAX_CODE_AGENT_TRACE_PAGE_LIMIT = 100;
|
|
const DEFAULT_CODE_AGENT_TURN_STATUS_REFRESH_TIMEOUT_MS = 2500;
|
|
const DEFAULT_CODE_AGENT_PROJECT_ID = "prj_hwpod_workbench";
|
|
const DEFAULT_CODE_AGENT_PROVIDER_PROFILE = "deepseek";
|
|
const CODE_AGENT_TERMINAL_STATUSES = new Set(["completed", "failed", "blocked", "timeout", "cancelled", "canceled"]);
|
|
const CODE_AGENT_PROVIDER_PROFILE_ALIASES = Object.freeze({ codex: "codex-api" });
|
|
const CODE_AGENT_PROVIDER_PROFILE_ID_PATTERN = /^[a-z0-9][a-z0-9.-]{0,63}$/u;
|
|
const CODE_AGENT_PROVIDER_PROFILE_LABELS = Object.freeze({
|
|
"deepseek": "DeepSeek",
|
|
"codex-api": "Codex API",
|
|
"gpt.pika": "gpt.pika",
|
|
"minimax-m3": "MiniMax-M3 via AgentRun",
|
|
"runtime-default": "运行默认"
|
|
});
|
|
const DEFAULT_CODE_AGENT_CODEX_API_MODEL = "gpt-5.5";
|
|
const DEFAULT_CODE_AGENT_CODEX_API_BASE_URL = "http://127.0.0.1:49280/responses";
|
|
const DEFAULT_CODE_AGENT_DEEPSEEK_MODEL = "deepseek-chat";
|
|
const DEFAULT_CODE_AGENT_MINIMAX_M3_MODEL = "MiniMax-M3";
|
|
|
|
export async function handleCodeAgentSteerHttp(request, response, options) {
|
|
const body = await readBody(request, options.bodyLimitBytes);
|
|
let params = {};
|
|
|
|
try {
|
|
params = body ? JSON.parse(body) : {};
|
|
} catch (error) {
|
|
sendJson(response, 400, {
|
|
...createCodeAgentErrorPayload({
|
|
code: "parse_error",
|
|
message: "Invalid JSON body",
|
|
reason: error.message,
|
|
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
|
layer: "api",
|
|
retryable: true,
|
|
route: "/v1/agent/chat/steer"
|
|
})
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
|
sendJson(response, 400, {
|
|
...createCodeAgentErrorPayload({
|
|
code: "invalid_params",
|
|
message: "Code Agent steer body must be a JSON object",
|
|
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
|
layer: "api",
|
|
retryable: true,
|
|
route: "/v1/agent/chat/steer"
|
|
})
|
|
});
|
|
return;
|
|
}
|
|
|
|
const traceId = safeTraceId(getHeader(request, "x-trace-id") || params.traceId || params.targetTraceId);
|
|
const message = firstNonEmptyValue(params.message, params.prompt, params.text);
|
|
if (!traceId || !message) {
|
|
sendJson(response, 400, {
|
|
...createCodeAgentErrorPayload({
|
|
code: !traceId ? "steer_trace_missing" : "steer_message_missing",
|
|
message: !traceId ? "traceId is required to steer the current Code Agent turn." : "message is required to steer the current Code Agent turn.",
|
|
traceId: traceId || "trc_unassigned",
|
|
layer: "api",
|
|
retryable: true,
|
|
route: "/v1/agent/chat/steer"
|
|
})
|
|
});
|
|
return;
|
|
}
|
|
|
|
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
|
|
const durableResult = await loadPersistedAgentRunResult(traceId, options);
|
|
const currentResult = durableResult ?? options.codeAgentChatResults?.get?.(traceId);
|
|
const projectionContext = await readCodeAgentCompatProjection(traceId, options);
|
|
if (!canAccessOwnedResult(currentResult, options.actor)) {
|
|
sendJson(response, 403, {
|
|
ok: false,
|
|
status: "forbidden",
|
|
traceId,
|
|
error: { code: "code_agent_trace_forbidden", message: "Code Agent trace is not visible to the current actor" }
|
|
});
|
|
return;
|
|
}
|
|
const projectionStoreBlocker = codeAgentProjectionStoreBlocker(projectionContext);
|
|
if (projectionStoreBlocker) {
|
|
sendJson(response, 503, {
|
|
ok: false,
|
|
accepted: false,
|
|
status: "blocked",
|
|
traceId,
|
|
route: "/v1/agent/chat/steer",
|
|
error: {
|
|
...projectionStoreBlocker,
|
|
code: "projection_store_unavailable",
|
|
layer: projectionStoreBlocker.layer ?? "runtime-store",
|
|
category: projectionStoreBlocker.category ?? "projection-store-unavailable",
|
|
retryable: true,
|
|
message: projectionStoreBlocker.message ?? "Workbench projection store is unavailable; steer is blocked until terminal authority can be checked.",
|
|
traceId,
|
|
route: "/v1/agent/chat/steer"
|
|
},
|
|
valuesPrinted: false
|
|
});
|
|
return;
|
|
}
|
|
if (!currentResult?.agentRun?.runId || !currentResult?.agentRun?.commandId) {
|
|
sendJson(response, 404, {
|
|
ok: false,
|
|
status: "not_found",
|
|
traceId,
|
|
error: { code: "steer_trace_not_found", message: "No AgentRun-backed Code Agent turn was found for the requested traceId." },
|
|
route: "/v1/agent/chat/steer",
|
|
runnerTrace: traceStore.snapshot(traceId)
|
|
});
|
|
return;
|
|
}
|
|
const projectedTerminalStatus = normalizeTurnStatus(
|
|
projectionContext.trace?.terminalEvidence?.status,
|
|
projectionContext.trace?.status
|
|
);
|
|
if (isTraceCommandTerminalStatus(projectedTerminalStatus)) {
|
|
sendJson(response, 409, {
|
|
ok: false,
|
|
accepted: false,
|
|
status: "blocked",
|
|
traceId,
|
|
route: "/v1/agent/chat/steer",
|
|
sessionId: safeSessionId(currentResult.sessionId ?? params.sessionId) || null,
|
|
threadId: safeOpaqueId(currentResult.threadId ?? params.threadId) || null,
|
|
agentRun: currentResult.agentRun,
|
|
runnerTrace: traceStore.snapshot(traceId),
|
|
error: {
|
|
code: "steer_trace_terminal",
|
|
layer: "api",
|
|
category: "not_in_flight",
|
|
retryable: false,
|
|
message: `Trace ${traceId} is already terminal and cannot accept a steer command.`,
|
|
traceId,
|
|
route: "/v1/agent/chat/steer",
|
|
toolName: "agentrun.command.steer"
|
|
},
|
|
valuesPrinted: false
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await recordCodeAgentSessionInputFact({
|
|
params: {
|
|
...params,
|
|
sessionId: currentResult.sessionId ?? currentResult.session?.sessionId ?? params.sessionId,
|
|
ownerUserId: options.actor?.id,
|
|
ownerRole: options.actor?.role
|
|
},
|
|
options,
|
|
traceId,
|
|
delivery: "steer",
|
|
status: "promoted",
|
|
commandId: currentResult.agentRun.commandId
|
|
});
|
|
const payload = await steerAgentRunChatTurn({
|
|
traceId,
|
|
currentResult,
|
|
params: {
|
|
...params,
|
|
message,
|
|
ownerUserId: options.actor?.id ?? params.ownerUserId,
|
|
ownerRole: options.actor?.role ?? params.ownerRole
|
|
},
|
|
options,
|
|
traceStore
|
|
});
|
|
sendJson(response, 202, payload);
|
|
} catch (error) {
|
|
traceStore.append(traceId, {
|
|
type: "backend",
|
|
status: "failed",
|
|
label: "agentrun:steer:failed",
|
|
errorCode: error?.code ?? "agentrun_steer_failed",
|
|
message: error?.message ?? "AgentRun steer command failed.",
|
|
terminal: false,
|
|
valuesPrinted: false
|
|
});
|
|
sendJson(response, error?.statusCode === 404 ? 404 : 409, {
|
|
ok: false,
|
|
accepted: false,
|
|
status: "failed",
|
|
traceId,
|
|
route: "/v1/agent/chat/steer",
|
|
sessionId: safeSessionId(currentResult.sessionId ?? params.sessionId) || null,
|
|
threadId: safeOpaqueId(currentResult.threadId ?? params.threadId) || null,
|
|
agentRun: currentResult.agentRun,
|
|
runnerTrace: traceStore.snapshot(traceId),
|
|
error: {
|
|
code: error?.code ?? "agentrun_steer_failed",
|
|
layer: "agentrun",
|
|
category: "steer_failed",
|
|
retryable: true,
|
|
message: error?.message ?? "AgentRun steer command failed.",
|
|
traceId,
|
|
route: "/v1/agent/chat/steer",
|
|
toolName: "agentrun.command.steer"
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
export async function handleCodeAgentTraceHttp(request, response, url, options) {
|
|
const startedAt = nowMs();
|
|
const parts = url.pathname.split("/").filter(Boolean);
|
|
const traceId = decodeURIComponent(parts[3] ?? "");
|
|
const streamSegment = parts[4];
|
|
const pageOptions = codeAgentTracePageOptions(url);
|
|
if (!/^trc_[A-Za-z0-9_.:-]+$/u.test(traceId)) {
|
|
sendJson(response, 400, {
|
|
error: {
|
|
code: "invalid_trace_id",
|
|
message: "traceId must start with trc_ and contain only safe identifier characters"
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
const requestOptions = codeAgentRequestScopedOptions(options);
|
|
const projectMismatch = await measureCodeAgentHttpPhase(requestOptions, "trace_project_check", () => traceProjectMismatchSnapshot(traceId, url, requestOptions, "trace"));
|
|
if (projectMismatch) {
|
|
sendJson(response, projectMismatch.statusCode, projectMismatch.body);
|
|
return;
|
|
}
|
|
const context = await measureCodeAgentHttpPhase(requestOptions, "trace_projection_read", () => readCodeAgentCompatProjection(traceId, requestOptions));
|
|
if (context.statusCode) {
|
|
sendJson(response, context.statusCode, context.body);
|
|
return;
|
|
}
|
|
|
|
if (streamSegment === "stream") {
|
|
sendJson(response, 410, {
|
|
ok: false,
|
|
status: "removed",
|
|
traceId,
|
|
error: {
|
|
code: "trace_store_sse_removed",
|
|
message: "Trace-store SSE was removed; use the committed Workbench projection event stream.",
|
|
workbenchEventsUrl: `/v1/workbench/events?traceId=${encodeURIComponent(traceId)}`
|
|
},
|
|
valuesRedacted: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
const diagnosticTrace = (requestOptions.traceStore ?? defaultCodeAgentTraceStore).snapshot(traceId);
|
|
const trace = mergeCodeAgentDiagnosticTrace(context.trace, diagnosticTrace, traceId);
|
|
const diagnosticFound = trace.events.length > 0;
|
|
const body = await measureCodeAgentHttpPhase(requestOptions, "trace_paginate", () => {
|
|
return paginateTraceSnapshot(trace, pageOptions);
|
|
});
|
|
const statusCode = context.found || diagnosticFound ? 200 : 404;
|
|
void emitCodeAgentOtelSpan("trace_events_read", traceId, requestOptions.env ?? process.env, {
|
|
startTimeMs: startedAt,
|
|
attributes: {
|
|
"http.method": "GET",
|
|
"http.route": "/v1/agent/traces/:traceId",
|
|
"http.status_code": statusCode,
|
|
found: Boolean(context.found),
|
|
returnedEvents: Array.isArray(body?.events) ? body.events.length : 0,
|
|
sinceSeq: pageOptions.sinceSeq,
|
|
limit: pageOptions.limit,
|
|
fromSeq: body?.range?.fromSeq ?? null,
|
|
toSeq: body?.range?.toSeq ?? null,
|
|
totalEvents: body?.range?.total ?? body?.eventCount ?? null,
|
|
hasMore: Boolean(body?.hasMore),
|
|
fullTraceLoaded: Boolean(body?.fullTraceLoaded)
|
|
}
|
|
});
|
|
sendJson(response, statusCode, codeAgentCompatProjectionPayload(body, context));
|
|
}
|
|
|
|
function mergeCodeAgentDiagnosticTrace(projectionTrace, diagnosticTrace, traceId) {
|
|
const projected = projectionTrace && typeof projectionTrace === "object" ? projectionTrace : {};
|
|
const diagnostics = diagnosticTrace && typeof diagnosticTrace === "object" ? diagnosticTrace : {};
|
|
const merged = new Map();
|
|
for (const [source, events] of [["diagnostic", diagnostics.events], ["projection", projected.events]]) {
|
|
for (const [index, event] of (Array.isArray(events) ? events : []).entries()) {
|
|
const originalSeq = traceEventSequence(event, index);
|
|
const key = textValue(event?.id ?? event?.sourceEventId) || `${originalSeq}:${textValue(event?.label ?? event?.type)}:${textValue(event?.createdAt ?? event?.occurredAt)}`;
|
|
const normalized = source === "projection"
|
|
? {
|
|
...event,
|
|
projectedSeq: Number.isFinite(Number(event?.projectedSeq)) ? Number(event.projectedSeq) : originalSeq,
|
|
authority: "workbench-projection"
|
|
}
|
|
: {
|
|
...event,
|
|
sourceSeq: Number.isFinite(Number(event?.sourceSeq)) ? Number(event.sourceSeq) : originalSeq,
|
|
terminal: false,
|
|
sealed: false,
|
|
diagnosticOnly: true,
|
|
authority: "trace-store-diagnostics"
|
|
};
|
|
merged.set(key, { event: normalized, source, originalSeq, sourceIndex: index, key });
|
|
}
|
|
}
|
|
const events = [...merged.values()]
|
|
.sort((left, right) => left.originalSeq - right.originalSeq
|
|
|| (left.source === right.source ? 0 : left.source === "projection" ? -1 : 1)
|
|
|| left.sourceIndex - right.sourceIndex
|
|
|| left.key.localeCompare(right.key))
|
|
.map(({ event }, index) => ({ ...event, seq: index + 1, paginationSeq: index + 1 }));
|
|
const projectionFound = projected.status && projected.status !== "missing";
|
|
return {
|
|
...projected,
|
|
traceId,
|
|
status: projectionFound ? projected.status : events.length > 0 ? "running" : "missing",
|
|
terminal: projected.terminal === true,
|
|
sealed: projected.sealed === true,
|
|
events,
|
|
eventCount: events.length,
|
|
diagnosticEventCount: events.filter((event) => event.diagnosticOnly === true).length,
|
|
terminalAuthority: "workbench-projection",
|
|
valuesRedacted: true
|
|
};
|
|
}
|
|
|
|
function codeAgentProjectionStoreBlocker(context = {}) {
|
|
const candidates = [
|
|
context?.projection?.blocker,
|
|
context?.trace?.projection?.blocker,
|
|
context?.trace?.blocker,
|
|
context?.result?.projection?.blocker,
|
|
context?.result?.blocker
|
|
];
|
|
return candidates.find((blocker) => blocker?.code === "projection_store_unavailable") ?? null;
|
|
}
|
|
|
|
function codeAgentTracePageOptions(url) {
|
|
const sinceSeq = nonNegativeInteger(url.searchParams.get("sinceSeq") ?? url.searchParams.get("since"), 0);
|
|
const requestedLimit = parsePositiveInteger(url.searchParams.get("limit"), DEFAULT_CODE_AGENT_TRACE_PAGE_LIMIT);
|
|
return {
|
|
sinceSeq,
|
|
limit: Math.min(requestedLimit, MAX_CODE_AGENT_TRACE_PAGE_LIMIT)
|
|
};
|
|
}
|
|
|
|
function paginateTraceSnapshot(snapshot, { sinceSeq, limit }) {
|
|
const sourceEvents = Array.isArray(snapshot?.events) ? snapshot.events : [];
|
|
const indexedEvents = sourceEvents.map((event, index) => ({ event, seq: traceEventSequence(event, index) }));
|
|
const firstIndex = indexedEvents.findIndex((item) => item.seq > sinceSeq);
|
|
const startIndex = firstIndex >= 0 ? firstIndex : indexedEvents.length;
|
|
const pageItems = indexedEvents.slice(startIndex, startIndex + limit);
|
|
const events = pageItems.map((item) => item.event);
|
|
const fromSeq = pageItems.length > 0 ? pageItems[0].seq : null;
|
|
const toSeq = pageItems.length > 0 ? pageItems[pageItems.length - 1].seq : null;
|
|
const hasMore = startIndex + pageItems.length < indexedEvents.length;
|
|
const totalEvents = snapshot?.eventCount ?? indexedEvents.length;
|
|
return {
|
|
...snapshot,
|
|
events,
|
|
eventCount: totalEvents,
|
|
range: {
|
|
sinceSeq,
|
|
fromSeq,
|
|
toSeq,
|
|
limit,
|
|
returned: events.length,
|
|
total: totalEvents
|
|
},
|
|
truncated: hasMore,
|
|
hasMore,
|
|
nextSinceSeq: pageItems.length > 0 ? toSeq : sinceSeq,
|
|
fullTraceLoaded: !hasMore
|
|
};
|
|
}
|
|
|
|
function traceEventSequence(event, index) {
|
|
const seq = Number(event?.seq);
|
|
return Number.isFinite(seq) && seq > 0 ? Math.trunc(seq) : index + 1;
|
|
}
|
|
|
|
function nonNegativeInteger(value, fallback) {
|
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
|
}
|