Files
pikasTech-HWLAB/internal/cloud/code-agent-session-registry.ts
T

631 lines
25 KiB
TypeScript

import { randomUUID } from "node:crypto";
import {
CODE_AGENT_SESSION_LIFECYCLE_STATUSES,
CODE_AGENT_SESSION_STATUS_ALIASES,
codeAgentSessionLifecycleSummary,
decorateCodeAgentSession
} from "./code-agent-session-lifecycle.ts";
export const DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
export const DEFAULT_CODE_AGENT_MAX_SESSIONS = 200;
export const DEFAULT_CODE_AGENT_MAX_CONVERSATION_FACTS = 12;
export const DEFAULT_CODE_AGENT_MAX_FACT_TOOL_CALLS = 8;
export const DEFAULT_CODE_AGENT_MAX_FACT_SKILLS = 24;
export const CODE_AGENT_SESSION_STATUSES = Object.freeze([
"creating",
"ready",
"busy",
"idle",
"timeout",
"error",
"canceled",
"interrupted",
"expired",
"failed"
]);
export function createCodeAgentSessionRegistry(options = {}) {
const idleTimeoutMs = positiveInteger(options.idleTimeoutMs, DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS);
const maxSessions = positiveInteger(options.maxSessions, DEFAULT_CODE_AGENT_MAX_SESSIONS);
const maxConversationFacts = positiveInteger(options.maxConversationFacts, DEFAULT_CODE_AGENT_MAX_CONVERSATION_FACTS);
const maxFactToolCalls = positiveInteger(options.maxFactToolCalls, DEFAULT_CODE_AGENT_MAX_FACT_TOOL_CALLS);
const maxFactSkills = positiveInteger(options.maxFactSkills, DEFAULT_CODE_AGENT_MAX_FACT_SKILLS);
const idFactory = typeof options.idFactory === "function" ? options.idFactory : () => `ses_${randomUUID()}`;
const defaultNow = options.now;
const sessions = new Map();
const conversations = new Map();
const conversationFacts = new Map();
function acquire(params = {}) {
const timestamp = timestampFor(params.now ?? defaultNow);
const timestampMs = Date.parse(timestamp);
const conversationId = requiredId(params.conversationId, "cnv");
const requestedSessionId = optionalId(params.sessionId);
const mappedSessionId = conversations.get(conversationId) ?? null;
if (mappedSessionId && requestedSessionId && mappedSessionId !== requestedSessionId) {
const mappedSession = sessions.get(mappedSessionId) ?? null;
return blockedAcquire({
code: "session_reuse_conflict",
summary: `conversationId ${conversationId} is already bound to sessionId ${mappedSessionId}; requested ${requestedSessionId}.`,
session: mappedSession,
timestamp,
traceId: params.traceId
});
}
const effectiveSessionId = mappedSessionId || requestedSessionId || requiredId(idFactory(), "ses");
let session = sessions.get(effectiveSessionId) ?? null;
const reused = Boolean(session);
if (session && sessionExpired(session, timestampMs)) {
session.status = "expired";
session.updatedAt = timestamp;
session.lastTraceId = optionalId(params.traceId);
return blockedAcquire({
code: "session_expired",
summary: `Code Agent runner session ${effectiveSessionId} expired after ${session.idleTimeoutMs}ms idle timeout.`,
session,
timestamp,
traceId: params.traceId
});
}
if (session?.status === "busy") {
session.updatedAt = timestamp;
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
return blockedAcquire({
code: "session_busy",
summary: `Code Agent runner session ${effectiveSessionId} is already busy with traceId ${session.currentTraceId ?? "unknown"}.`,
session,
timestamp,
traceId: params.traceId
});
}
if (session && ["failed", "interrupted", "timeout", "error", "canceled"].includes(session.status)) {
session.updatedAt = timestamp;
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
return blockedAcquire({
code: `session_${session.status}`,
summary: `Code Agent runner session ${effectiveSessionId} is ${session.status}; create a new session before retrying.`,
session,
timestamp,
traceId: params.traceId
});
}
if (!session) {
session = {
sessionId: effectiveSessionId,
conversationIds: new Set(),
status: "creating",
workspace: params.workspace ?? null,
sandbox: params.sandbox ?? "read-only",
runnerKind: params.runnerKind ?? "hwlab-readonly-runner",
sessionMode: params.sessionMode ?? params.implementationType ?? "controlled-readonly-session-registry",
capabilityLevel: params.capabilityLevel ?? "read-only-session-tools",
implementationType: params.implementationType ?? "controlled-readonly-session-registry",
createdAt: timestamp,
updatedAt: timestamp,
idleTimeoutMs,
expiresAt: plusMs(timestampMs, idleTimeoutMs),
lastTraceId: optionalId(params.traceId),
currentTraceId: null,
turn: 0,
durable: false,
longLivedSession: true,
codexStdio: false,
readOnly: true,
writeCapable: false,
secretMaterialStored: false
};
sessions.set(effectiveSessionId, session);
}
session.conversationIds.add(conversationId);
session.workspace = params.workspace ?? session.workspace;
session.sandbox = params.sandbox ?? session.sandbox;
session.runnerKind = params.runnerKind ?? session.runnerKind;
session.sessionMode = params.sessionMode ?? session.sessionMode;
session.capabilityLevel = params.capabilityLevel ?? session.capabilityLevel;
session.implementationType = params.implementationType ?? session.implementationType;
session.status = "busy";
session.updatedAt = timestamp;
session.expiresAt = plusMs(timestampMs, idleTimeoutMs);
session.lastTraceId = optionalId(params.traceId);
session.currentTraceId = optionalId(params.traceId);
session.turn += 1;
conversations.set(conversationId, effectiveSessionId);
prune();
return {
ok: true,
reused,
session: publicSession(session, { conversationId, reused, timestamp })
};
}
function release(sessionId, params = {}) {
const session = sessions.get(requiredId(sessionId, "ses")) ?? null;
if (!session) return null;
const timestamp = timestampFor(params.now ?? defaultNow);
const timestampMs = Date.parse(timestamp);
session.status = CODE_AGENT_SESSION_STATUSES.includes(params.status) ? params.status : "idle";
session.updatedAt = timestamp;
session.expiresAt = plusMs(timestampMs, session.idleTimeoutMs);
session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId;
session.currentTraceId = null;
session.statusReason = params.statusReason ?? null;
return publicSession(session, {
conversationId: params.conversationId,
reused: params.reused,
timestamp
});
}
function fail(sessionId, params = {}) {
return release(sessionId, { ...params, status: "failed" });
}
function interrupt(sessionId, params = {}) {
return release(sessionId, { ...params, status: "interrupted" });
}
function get(sessionId, params = {}) {
const session = sessions.get(requiredId(sessionId, "ses")) ?? null;
return session ? publicSession(session, params) : null;
}
function getByConversation(conversationId, params = {}) {
const sessionId = conversations.get(requiredId(conversationId, "cnv"));
return sessionId ? get(sessionId, { ...params, conversationId }) : null;
}
function findSession(params = {}) {
const conversationId = optionalId(params.conversationId);
const sessionId = optionalId(params.sessionId);
const traceId = optionalId(params.traceId);
const threadId = optionalId(params.threadId);
if (sessionId) {
const session = get(sessionId, { conversationId });
if (session) return session;
}
if (conversationId) {
const session = getByConversation(conversationId, { conversationId });
if (session) return session;
}
for (const session of sessions.values()) {
const conversationIds = [...session.conversationIds];
if (conversationId && conversationIds.includes(conversationId)) return publicSession(session, { conversationId });
if (traceId && (session.lastTraceId === traceId || session.currentTraceId === traceId)) return publicSession(session, { conversationId: conversationIds[0] ?? null });
if (threadId && session.threadId === threadId) return publicSession(session, { conversationId: conversationIds[0] ?? null });
}
return null;
}
function inspect(params = {}) {
const session = findSession(params);
const conversationId = optionalId(params.conversationId) ?? session?.conversationId ?? session?.conversationIds?.[0] ?? null;
const facts = conversationId ? getConversationFacts(conversationId) : null;
const factsFound = hasConversationFacts(facts);
const requestedTraceId = optionalId(params.traceId);
const traceIds = uniqueStrings([
facts?.latestTraceId,
session?.currentTraceId,
session?.lastTraceId,
...(Array.isArray(facts?.traceIds) ? facts.traceIds : [])
]);
const found = Boolean(session || factsFound || traceIds.length > 0);
return {
ok: found,
action: "code-agent.session.inspect",
status: found ? "found" : "not_found",
query: {
conversationId: optionalId(params.conversationId),
sessionId: optionalId(params.sessionId),
threadId: optionalId(params.threadId),
traceId: requestedTraceId
},
session,
conversationFacts: facts,
traceIds,
latestTraceId: traceIds[0] ?? null,
valuesRedacted: true,
secretMaterialStored: false
};
}
function recordFact(conversationIdValue, fact, params = {}) {
const conversationId = requiredId(conversationIdValue, "cnv");
const timestamp = timestampFor(params.now ?? defaultNow);
const sanitized = sanitizeConversationFact(fact, {
conversationId,
timestamp,
maxFactToolCalls,
maxFactSkills
});
if (!sanitized) return getConversationFacts(conversationId);
const existing = conversationFacts.get(conversationId) ?? {
conversationId,
facts: []
};
existing.facts.push(sanitized);
existing.facts = existing.facts.slice(-maxConversationFacts);
conversationFacts.set(conversationId, existing);
return getConversationFacts(conversationId);
}
function getConversationFacts(conversationIdValue, params = {}) {
const conversationId = requiredId(conversationIdValue, "cnv");
const stored = conversationFacts.get(conversationId);
const facts = stored?.facts ?? [];
const session = getByConversation(conversationId, params);
return publicConversationFacts({
conversationId,
session,
facts,
maxFactToolCalls,
maxConversationFacts
});
}
function expireIdle(params = {}) {
const timestamp = timestampFor(params.now ?? defaultNow);
const timestampMs = Date.parse(timestamp);
let expired = 0;
for (const session of sessions.values()) {
if (!sessionExpired(session, timestampMs)) continue;
session.status = "expired";
session.updatedAt = timestamp;
session.currentTraceId = null;
expired += 1;
}
return expired;
}
function describe() {
return {
kind: "code-agent-session-registry",
implementationType: "controlled-readonly-session-registry",
status: "available",
sessionCount: sessions.size,
conversationFactCount: conversationFacts.size,
maxSessions,
maxConversationFacts,
idleTimeoutMs,
statuses: [...CODE_AGENT_SESSION_STATUSES],
lifecycleStatuses: [...CODE_AGENT_SESSION_LIFECYCLE_STATUSES],
statusAliases: { ...CODE_AGENT_SESSION_STATUS_ALIASES },
sessionMode: "controlled-readonly-session-registry",
recentSessions: [...sessions.values()]
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
.slice(0, 10)
.map((session) => publicSession(session)),
longLivedCodexStdio: false,
longLivedSession: true,
durable: false,
secretMaterialStored: false,
valuesRedacted: true
};
}
function clear() {
sessions.clear();
conversations.clear();
conversationFacts.clear();
}
function prune() {
if (sessions.size <= maxSessions) return;
const sorted = [...sessions.entries()]
.sort((a, b) => String(a[1].updatedAt).localeCompare(String(b[1].updatedAt)));
for (const [sessionId, session] of sorted.slice(0, sessions.size - maxSessions)) {
sessions.delete(sessionId);
for (const conversationId of session.conversationIds) {
if (conversations.get(conversationId) === sessionId) {
conversations.delete(conversationId);
}
conversationFacts.delete(conversationId);
}
}
}
return {
acquire,
release,
fail,
interrupt,
get,
getByConversation,
findSession,
inspect,
recordFact,
getConversationFacts,
expireIdle,
describe,
clear
};
}
function blockedAcquire({ code, summary, session, timestamp, traceId }) {
const publicEvidence = session
? publicSession(session, { timestamp, reused: true })
: {
status: "failed",
updatedAt: timestamp,
lastTraceId: optionalId(traceId),
secretMaterialStored: false,
valuesRedacted: true
};
return {
ok: false,
code,
message: summary,
session: publicEvidence,
blocker: {
code,
sourceIssue: "pikasTech/HWLAB#317",
summary
}
};
}
function publicSession(session, { conversationId = null, reused = null } = {}) {
const conversationIds = [...session.conversationIds];
return decorateCodeAgentSession({
sessionId: session.sessionId,
conversationId: conversationId ?? conversationIds[0] ?? null,
conversationIds,
status: session.status,
workspace: session.workspace,
sandbox: session.sandbox,
runnerKind: session.runnerKind,
sessionMode: session.sessionMode,
capabilityLevel: session.capabilityLevel,
implementationType: session.implementationType,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
idleTimeoutMs: session.idleTimeoutMs,
expiresAt: session.expiresAt,
lastTraceId: session.lastTraceId,
currentTraceId: session.currentTraceId,
turn: session.turn,
reused: reused === null ? undefined : Boolean(reused),
durable: session.durable === true,
durableSession: session.durable === true,
longLivedSession: session.longLivedSession === true,
codexStdio: session.codexStdio === true,
readOnly: session.readOnly === true,
writeCapable: session.writeCapable === true,
secretMaterialStored: false,
valuesRedacted: true,
...(session.statusReason ? { statusReason: session.statusReason } : {})
}, { reused });
}
function publicConversationFacts({ conversationId, session, facts, maxFactToolCalls, maxConversationFacts }) {
const latest = facts.at(-1) ?? null;
const latestOperational = [...facts].reverse().find((fact) => fact.kind !== "session_context") ?? latest;
const latestSessionFact = [...facts].reverse().find((fact) => fact.sessionId && fact.sessionMode !== "provider-text-request") ?? latestOperational;
const latestSkills = [...facts].reverse().find((fact) => fact.skills?.status && fact.skills.status !== "not_requested")?.skills ?? null;
const latestWorkspace = [...facts].reverse().find((fact) => fact.kind !== "session_context" && fact.workspace)?.workspace ??
[...facts].reverse().find((fact) => fact.workspace)?.workspace ??
session?.workspace ??
null;
const traceIds = [...new Set(facts.map((fact) => fact.traceId).filter(Boolean))].slice(-6);
const recentToolCalls = facts
.flatMap((fact) => fact.toolCalls?.items ?? [])
.slice(-maxFactToolCalls);
return {
conversationId,
sessionId: session?.sessionId ?? latestSessionFact?.sessionId ?? latest?.sessionId ?? null,
sessionStatus: session?.status ?? latestSessionFact?.sessionStatus ?? latest?.sessionStatus ?? null,
sessionLifecycleStatus: session?.lifecycleStatus ??
normalizeFactLifecycleStatus(latestSessionFact?.sessionLifecycleStatus ?? latest?.sessionLifecycleStatus ?? latestSessionFact?.sessionStatus ?? latest?.sessionStatus),
sessionMode: session?.sessionMode ?? latestSessionFact?.sessionMode ?? latest?.sessionMode ?? null,
capabilityLevel: latestOperational?.capabilityLevel ?? session?.capabilityLevel ?? latest?.capabilityLevel ?? null,
runnerKind: latestOperational?.runnerKind ?? session?.runnerKind ?? latest?.runnerKind ?? null,
latestProvider: latestOperational?.provider ?? latest?.provider ?? null,
latestBackend: latestOperational?.backend ?? latest?.backend ?? null,
workspace: latestWorkspace,
sandbox: latestOperational?.sandbox ?? session?.sandbox ?? latest?.sandbox ?? null,
turnCount: facts.length,
maxFacts: maxConversationFacts,
traceIds,
latestTraceId: traceIds.at(-1) ?? session?.lastTraceId ?? null,
latestSkills,
recentToolCalls,
facts: facts.map((fact) => ({ ...fact })),
valuesRedacted: true,
secretMaterialStored: false,
summary: conversationFactsSummary({ facts, session, latestSkills, latestWorkspace, traceIds, recentToolCalls })
};
}
function sanitizeConversationFact(fact, { conversationId, timestamp, maxFactToolCalls, maxFactSkills }) {
if (!fact || typeof fact !== "object") return null;
const session = fact.session && typeof fact.session === "object" ? fact.session : null;
const runner = fact.runner && typeof fact.runner === "object" ? fact.runner : null;
const runnerTrace = fact.runnerTrace && typeof fact.runnerTrace === "object" ? fact.runnerTrace : null;
const toolCalls = sanitizeToolCalls(fact.toolCalls, maxFactToolCalls);
const skills = sanitizeSkillsFact(fact.skills, maxFactSkills);
return {
kind: safeFactText(fact.kind ?? "runner_turn", 80),
conversationId,
sessionId: safeFactText(session?.sessionId ?? fact.sessionId ?? runner?.sessionId, 120),
sessionStatus: safeFactText(session?.status ?? fact.sessionStatus, 40),
sessionLifecycleStatus: safeFactText(
session?.lifecycleStatus ??
runnerTrace?.sessionLifecycleStatus ??
fact.sessionLifecycleStatus ??
normalizeFactLifecycleStatus(session?.status ?? fact.sessionStatus),
40
),
sessionMode: safeFactText(fact.sessionMode ?? session?.sessionMode ?? runner?.sessionMode ?? runnerTrace?.sessionMode, 120),
sessionReused: fact.sessionReuse?.reused === true || session?.reused === true || runner?.sessionReused === true,
turn: numberOrNull(session?.turn ?? fact.sessionReuse?.turn ?? runnerTrace?.turn ?? runner?.turn),
recordedAt: timestamp,
traceId: safeFactText(fact.traceId ?? runnerTrace?.traceId, 120),
provider: safeFactText(fact.provider ?? runner?.provider, 120),
backend: safeFactText(fact.backend ?? runner?.backend, 160),
runnerKind: safeFactText(runner?.kind ?? runnerTrace?.runnerKind ?? fact.runnerKind, 120),
workspace: safeFactText(fact.workspace ?? runner?.workspace ?? runnerTrace?.workspace ?? session?.workspace, 240),
sandbox: safeFactText(fact.sandbox ?? runner?.sandbox ?? runnerTrace?.sandbox ?? session?.sandbox, 120),
capabilityLevel: safeFactText(fact.capabilityLevel ?? runner?.capabilityLevel ?? session?.capabilityLevel, 120),
toolCalls,
skills,
runnerTrace: sanitizeRunnerTrace(runnerTrace),
valuesRedacted: true,
secretMaterialStored: false
};
}
function sanitizeToolCalls(toolCalls, limit) {
const items = Array.isArray(toolCalls)
? toolCalls.slice(0, limit).map((toolCall) => ({
name: safeFactText(toolCall?.name, 120),
type: safeFactText(toolCall?.type, 80),
status: safeFactText(toolCall?.status, 40),
route: safeFactText(toolCall?.route ?? toolCall?.hwlabApi?.route, 120),
method: safeFactText(toolCall?.method, 16),
traceId: safeFactText(toolCall?.traceId, 120),
outputTruncated: toolCall?.outputTruncated === true
})).filter((toolCall) => toolCall.name || toolCall.status || toolCall.route)
: [];
return {
count: Array.isArray(toolCalls) ? toolCalls.length : 0,
items,
truncated: Array.isArray(toolCalls) && toolCalls.length > items.length
};
}
function sanitizeSkillsFact(skills, limit) {
if (!skills || typeof skills !== "object") {
return {
status: "not_requested",
count: 0,
totalCount: 0,
names: [],
blockers: [],
truncated: false
};
}
const items = Array.isArray(skills.items) ? skills.items : [];
const names = items
.map((item) => safeFactText(item?.name, 120))
.filter(Boolean)
.slice(0, limit);
return {
status: safeFactText(skills.status ?? "unknown", 40),
count: numberOrNull(skills.count ?? items.length) ?? items.length,
totalCount: numberOrNull(skills.totalCount ?? skills.count ?? items.length) ?? items.length,
names,
blockers: Array.isArray(skills.blockers)
? skills.blockers.map((blocker) => safeFactText(blocker?.code, 120)).filter(Boolean).slice(0, 6)
: [],
truncated: skills.truncated === true || items.length > names.length
};
}
function sanitizeRunnerTrace(runnerTrace) {
if (!runnerTrace || typeof runnerTrace !== "object") return null;
return {
traceId: safeFactText(runnerTrace.traceId, 120),
runnerKind: safeFactText(runnerTrace.runnerKind, 120),
sessionMode: safeFactText(runnerTrace.sessionMode, 120),
sessionId: safeFactText(runnerTrace.sessionId, 120),
sessionStatus: safeFactText(runnerTrace.sessionStatus, 80),
sessionLifecycleStatus: safeFactText(runnerTrace.sessionLifecycleStatus ?? normalizeFactLifecycleStatus(runnerTrace.sessionStatus), 80),
turn: numberOrNull(runnerTrace.turn),
events: Array.isArray(runnerTrace.events)
? runnerTrace.events.map((event) => safeFactText(event, 120)).filter(Boolean).slice(-8)
: [],
route: safeFactText(runnerTrace.route, 120),
status: safeFactText(runnerTrace.status, 80),
outputTruncated: runnerTrace.outputTruncated === true,
valuesPrinted: false
};
}
function normalizeFactLifecycleStatus(status) {
return codeAgentSessionLifecycleSummary({ sessionStatus: status }).status;
}
function conversationFactsSummary({ facts, session, latestSkills, latestWorkspace, traceIds, recentToolCalls }) {
const skillNames = latestSkills?.names?.slice(0, 5) ?? [];
const toolNames = recentToolCalls.map((toolCall) => [toolCall.name, toolCall.status].filter(Boolean).join(":")).filter(Boolean).slice(-5);
return [
`turns=${facts.length}`,
`sessionId=${session?.sessionId ?? facts.at(-1)?.sessionId ?? "unknown"}`,
`sessionStatus=${session?.status ?? facts.at(-1)?.sessionStatus ?? "unknown"}`,
latestWorkspace ? `workspace=${latestWorkspace}` : null,
latestSkills ? `skills=${latestSkills.totalCount ?? latestSkills.count ?? skillNames.length}${skillNames.length ? ` (${skillNames.join(",")})` : ""}` : "skills=not_requested",
traceIds.length ? `traceIds=${traceIds.join(",")}` : null,
toolNames.length ? `tools=${toolNames.join(",")}` : null
].filter(Boolean).join(" / ");
}
function safeFactText(value, maxLength) {
if (value === undefined || value === null || value === "") return null;
const normalized = String(value)
.replace(/\s+/gu, " ")
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gu, "Bearer ***")
.replace(/sk-[A-Za-z0-9._-]+/gu, "sk-***")
.replace(/([A-Za-z0-9_]*(?:TOKEN|KEY|SECRET|PASSWORD)[A-Za-z0-9_]*=)[^\s]+/giu, "$1***")
.replace(/\/\/[^/@\s]+@/gu, "//***@")
.trim();
if (!normalized) return null;
return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 12)}...[redacted]` : normalized;
}
function numberOrNull(value) {
return Number.isFinite(value) ? value : null;
}
function sessionExpired(session, timestampMs) {
if (!Number.isFinite(timestampMs) || session.status === "expired") return session.status === "expired";
return Date.parse(session.expiresAt) <= timestampMs;
}
function timestampFor(now) {
const value = typeof now === "function" ? now() : now;
if (typeof value === "string" && !Number.isNaN(Date.parse(value))) return value;
if (value instanceof Date && !Number.isNaN(value.valueOf())) return value.toISOString();
return new Date().toISOString();
}
function plusMs(timestampMs, offsetMs) {
return new Date(timestampMs + offsetMs).toISOString();
}
function requiredId(value, fallbackPrefix) {
const id = optionalId(value);
if (id) return id;
return `${fallbackPrefix}_${randomUUID()}`;
}
function optionalId(value) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function uniqueStrings(values) {
return [...new Set(values.map((value) => typeof value === "string" ? value.trim() : "").filter(Boolean))];
}
function hasConversationFacts(facts) {
return Boolean(facts && (
facts.sessionId ||
facts.sessionStatus ||
facts.latestTraceId ||
Number(facts.turnCount) > 0 ||
(Array.isArray(facts.traceIds) && facts.traceIds.length > 0) ||
(Array.isArray(facts.facts) && facts.facts.length > 0)
));
}
function positiveInteger(value, fallback) {
return Number.isInteger(value) && value > 0 ? value : fallback;
}