206 lines
9.5 KiB
TypeScript
206 lines
9.5 KiB
TypeScript
/*
|
|
* SPEC: PJ2026-0104010803 Workbench唯一投影 draft-2026-06-19-p1-agentrun-incremental-cursor; PJ2026-010205 HWLAB接入 draft-2026-06-17-r0.
|
|
* Responsibility: Pure cursor planning for AgentRun events projection; never derives cursor by scanning trace events or result payloads.
|
|
*/
|
|
|
|
const DEFAULT_AGENTRUN_EVENTS_PAGE_LIMIT = 500;
|
|
const MAX_AGENTRUN_EVENTS_PAGE_LIMIT = 1000;
|
|
|
|
export function buildAgentRunProjectionEventsFetchPlan({ projectionState = null, agentRun = null, pageLimit = DEFAULT_AGENTRUN_EVENTS_PAGE_LIMIT } = {}) {
|
|
const state = normalizeProjectionState(projectionState);
|
|
const mapping = normalizeAgentRunMapping(agentRun);
|
|
const runId = textValue(state?.sourceRunId ?? state?.runId ?? mapping.runId);
|
|
const commandId = textValue(state?.sourceCommandId ?? state?.commandId ?? mapping.commandId);
|
|
const stateSeq = maxNonNegativeInteger(state?.lastSourceSeq, state?.lastAgentRunSeq);
|
|
const mappingSeq = maxNonNegativeInteger(mapping.lastSeq);
|
|
return {
|
|
runId,
|
|
commandId,
|
|
afterSeq: Math.max(stateSeq, mappingSeq),
|
|
limit: boundedPageLimit(pageLimit),
|
|
cursorSource: state ? "projection-state" : "agent-run-result",
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
|
|
export function buildWorkbenchProjectionStateUpdate({
|
|
currentState = null,
|
|
result = null,
|
|
agentRun = null,
|
|
eventsResponse = null,
|
|
resultSyncState = null,
|
|
projectionStatus = null,
|
|
projectionHealth = null,
|
|
polling = null,
|
|
retryParams = null,
|
|
error = null,
|
|
now = () => new Date().toISOString()
|
|
} = {}) {
|
|
const previous = normalizeProjectionState(currentState) ?? {};
|
|
const mapping = normalizeAgentRunMapping(agentRun ?? result?.agentRun);
|
|
const timestamp = timestampValue(now);
|
|
const traceId = textValue(previous.traceId ?? result?.traceId ?? mapping.traceId);
|
|
const runId = textValue(mapping.runId) || textValue(previous.sourceRunId ?? previous.runId);
|
|
const commandId = textValue(mapping.commandId) || textValue(previous.sourceCommandId ?? previous.commandId);
|
|
const previousRunId = textValue(previous.sourceRunId ?? previous.runId);
|
|
const previousCommandId = textValue(previous.sourceCommandId ?? previous.commandId);
|
|
const sourceChanged = Boolean((previousRunId && runId && previousRunId !== runId) || (previousCommandId && commandId && previousCommandId !== commandId));
|
|
const previousCursorSeq = sourceChanged ? 0 : maxNonNegativeInteger(previous.lastSourceSeq, previous.lastAgentRunSeq);
|
|
const lastSourceSeq = Math.max(
|
|
previousCursorSeq,
|
|
maxNonNegativeInteger(mapping.lastSeq),
|
|
maxNonNegativeInteger(eventsResponse?.traceLastSeq)
|
|
);
|
|
const sourceLatestSeq = Math.max(
|
|
sourceChanged ? 0 : maxNonNegativeInteger(previous.sourceLatestSeq, previous.upstreamLatestSeq),
|
|
maxNonNegativeInteger(eventsResponse?.maxSeq),
|
|
lastSourceSeq
|
|
);
|
|
const nextRetryParams = normalizeProjectionRetryParams(retryParams) ?? normalizeProjectionRetryParams(previous.retryParams);
|
|
const nextPolling = normalizeProjectionPolling(polling);
|
|
const nextResultSyncState = textValue(resultSyncState ?? previous.resultSyncState) || "not_started";
|
|
const nextProjectionStatus = textValue(projectionStatus) || inferProjectionStatus({ lastSourceSeq, sourceLatestSeq, resultSyncState: nextResultSyncState, previous });
|
|
const nextProjectionHealth = textValue(projectionHealth) || (error ? "degraded" : "healthy");
|
|
const nextRetryAt = nextPolling?.nextRetryAt ?? (error ? previous.nextRetryAt ?? null : null);
|
|
return {
|
|
...previous,
|
|
traceId,
|
|
sessionId: textValue(previous.sessionId ?? result?.sessionId ?? mapping.sessionId) || null,
|
|
conversationId: textValue(previous.conversationId ?? result?.conversationId ?? mapping.conversationId) || null,
|
|
threadId: textValue(previous.threadId ?? result?.threadId ?? mapping.threadId) || null,
|
|
ownerUserId: textValue(previous.ownerUserId ?? result?.ownerUserId) || null,
|
|
ownerRole: textValue(previous.ownerRole ?? result?.ownerRole) || null,
|
|
managerUrl: textValue(previous.managerUrl ?? mapping.managerUrl) || null,
|
|
backendProfile: textValue(previous.backendProfile ?? mapping.backendProfile) || null,
|
|
providerId: textValue(previous.providerId ?? mapping.providerId) || null,
|
|
sourceRunId: runId,
|
|
sourceCommandId: commandId,
|
|
runId,
|
|
commandId,
|
|
lastSourceSeq,
|
|
lastAgentRunSeq: lastSourceSeq,
|
|
lastProjectedSeq: sourceChanged ? lastSourceSeq : Math.max(maxNonNegativeInteger(previous.lastProjectedSeq), lastSourceSeq),
|
|
sourceLatestSeq,
|
|
upstreamLatestSeq: sourceLatestSeq,
|
|
projectionStatus: nextProjectionStatus,
|
|
projectionHealth: nextProjectionHealth,
|
|
resultSyncState: nextResultSyncState,
|
|
lastProjectedAt: sourceChanged || lastSourceSeq > maxNonNegativeInteger(previous.lastSourceSeq, previous.lastAgentRunSeq) ? timestamp : previous.lastProjectedAt ?? timestamp,
|
|
lastResultSyncAt: nextResultSyncState === "synced" ? timestamp : previous.lastResultSyncAt ?? null,
|
|
lastErrorCode: error ? textValue(error.code ?? error.errorCode ?? "projection_sync_failed") : null,
|
|
lastErrorMessage: error ? textValue(error.message ?? "Projection sync failed.") : null,
|
|
failureCount: error ? maxNonNegativeInteger(previous.failureCount) + 1 : 0,
|
|
nextRetryAt,
|
|
pollCount: nextPolling?.pollCount ?? maxNonNegativeInteger(previous.pollCount),
|
|
noProgressPollCount: nextPolling?.noProgressPollCount ?? (error ? maxNonNegativeInteger(previous.noProgressPollCount) : 0),
|
|
backoffMs: nextPolling?.backoffMs ?? 0,
|
|
backoffReason: nextPolling?.backoffReason ?? null,
|
|
rootCause: nextPolling?.rootCause ?? null,
|
|
terminalConsistencyGap: nextPolling?.terminalConsistencyGap ?? null,
|
|
retryParams: nextRetryParams,
|
|
createdAt: previous.createdAt ?? timestamp,
|
|
updatedAt: timestamp,
|
|
valuesPrinted: false
|
|
};
|
|
}
|
|
|
|
export function agentRunResultSyncDeferred({ forceResultSync = false, options = {}, env = process.env } = {}) {
|
|
if (forceResultSync) return false;
|
|
if (options.deferAgentRunResultSync === true) return true;
|
|
const configured = String(env?.HWLAB_CODE_AGENT_AGENTRUN_RESULT_SYNC_IN_RUNNING ?? "").trim().toLowerCase();
|
|
return ["0", "false", "off", "no"].includes(configured);
|
|
}
|
|
|
|
function normalizeProjectionState(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
return value;
|
|
}
|
|
|
|
function normalizeAgentRunMapping(value) {
|
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
}
|
|
|
|
function normalizeProjectionRetryParams(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const message = textValue(value.message ?? value.prompt ?? value.text);
|
|
if (!message) return null;
|
|
const result = {
|
|
message,
|
|
prompt: message,
|
|
text: message,
|
|
sessionId: textValue(value.sessionId) || null,
|
|
conversationId: textValue(value.conversationId) || null,
|
|
threadId: textValue(value.threadId) || null,
|
|
projectId: textValue(value.projectId) || null,
|
|
ownerUserId: textValue(value.ownerUserId) || null,
|
|
ownerRole: textValue(value.ownerRole) || null,
|
|
providerProfile: textValue(value.providerProfile ?? value.codeAgentProviderProfile ?? value.backendProfile) || null,
|
|
codeAgentProviderProfile: textValue(value.codeAgentProviderProfile ?? value.providerProfile ?? value.backendProfile) || null,
|
|
backendProfile: textValue(value.backendProfile ?? value.providerProfile ?? value.codeAgentProviderProfile) || null,
|
|
providerId: textValue(value.providerId) || null,
|
|
valuesPrinted: false
|
|
};
|
|
for (const [key, item] of Object.entries(result)) {
|
|
if (item === null || item === "") delete result[key];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function normalizeProjectionPolling(value) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
const result = {
|
|
pollCount: maxNonNegativeInteger(value.pollCount),
|
|
noProgressPollCount: maxNonNegativeInteger(value.noProgressPollCount),
|
|
backoffMs: maxNonNegativeInteger(value.backoffMs),
|
|
backoffReason: clippedText(value.backoffReason, 80),
|
|
rootCause: clippedText(value.rootCause, 120),
|
|
terminalConsistencyGap: maxNonNegativeInteger(value.terminalConsistencyGap),
|
|
nextRetryAt: timestampText(value.nextRetryAt)
|
|
};
|
|
return result;
|
|
}
|
|
|
|
function inferProjectionStatus({ lastSourceSeq, sourceLatestSeq, resultSyncState, previous }) {
|
|
if (["pending", "timed_out", "failed"].includes(resultSyncState)) return "terminal";
|
|
if (sourceLatestSeq > lastSourceSeq) return "projecting";
|
|
return textValue(previous?.projectionStatus) || "projecting";
|
|
}
|
|
|
|
function boundedPageLimit(value) {
|
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
if (!Number.isInteger(parsed) || parsed <= 0) return DEFAULT_AGENTRUN_EVENTS_PAGE_LIMIT;
|
|
return Math.min(parsed, MAX_AGENTRUN_EVENTS_PAGE_LIMIT);
|
|
}
|
|
|
|
function maxNonNegativeInteger(...values) {
|
|
let max = 0;
|
|
for (const value of values) {
|
|
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
if (Number.isInteger(parsed) && parsed >= 0 && parsed > max) max = parsed;
|
|
}
|
|
return max;
|
|
}
|
|
|
|
function timestampValue(now) {
|
|
const value = typeof now === "function" ? now() : now;
|
|
const parsed = Date.parse(String(value ?? ""));
|
|
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : new Date().toISOString();
|
|
}
|
|
|
|
function textValue(value) {
|
|
return String(value ?? "").trim();
|
|
}
|
|
|
|
function clippedText(value, limit) {
|
|
const text = textValue(value);
|
|
if (!text) return null;
|
|
return text.length > limit ? text.slice(0, limit) : text;
|
|
}
|
|
|
|
function timestampText(value) {
|
|
const text = textValue(value);
|
|
if (!text) return null;
|
|
const parsed = Date.parse(text);
|
|
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
}
|