mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 16:51:28 +02:00
## Thinking Path > - Paperclip manages AI-agent work and reports failed runs. > - Some runs acquire a Daytona sandbox before model execution. > - Acquisition has one budget for creation, setup, and inline cleanup. > - A timeout currently carries cleanup ownership but loses the step that failed. > - This pull request carries a bounded observation through the existing error envelope. > - Operators can identify the first failed step without exposing provider responses or changing recovery. ## Linked Issues or Issue Description **What happened?** An acquisition timeout reports pending allocation cleanup without the first failed step. The provider already knows its current step, but that evidence is discarded at the worker boundary. A setup failure followed by slow cleanup is also indistinguishable from slow creation. **Expected behavior** The immediate setup-failure report should contain a fixed phase and bounded elapsed/budget values. Cleanup ownership, original messages, deadlines, retry policy, and task behavior should remain unchanged. **Steps to reproduce** Use the provider regression fixtures to stall creation, workspace setup, shell detection, expiry configuration, or sentinel upload. Also fail setup early and let cleanup consume the remaining budget. The previous source emits no phase diagnostic. **Paperclip version or commit** Base commit: `98e3cec78e8461774022a5ba0a777b09d4d16b96`. **Deployment mode** Hosted and self-hosted installations that use the Daytona sandbox provider and enable Sentry. Related public changes: #13979 established failed-allocation cleanup ownership; #14097 preserves the provider acquisition budget; #15637 carries bounded workspace-transfer evidence. Duplicate searches found no existing acquisition-phase diagnostic patch. ## What Changed - Capture the first acquisition failure before inline cleanup. Keep the observation if cleanup later reaches the deadline. - Add an optional, validated observation to the existing creation-cleanup envelope for acquire calls. Old ownership envelopes remain valid. Malformed observations do not alter cleanup. - Bind immediate reporting to an actual RPC error with matching company, environment, and run ownership. Project only three bounded fields from private host evidence. - Add provider, SDK, actual worker/client, PostgreSQL cleanup, collector, and real-Sentry regressions. Document the evidence limits. ## Verification - Passed 404 focused tests across the SDK cleanup envelope, complete Daytona provider suite, diagnostic collector, actual built worker/client subprocess, and mandatory real-Sentry SDK suite. - Passed 10 focused PostgreSQL cleanup-boundary cases. The filter excluded 152 unrelated cases. - Passed `pnpm -r typecheck` and `pnpm build` on the committed source. - Full `pnpm test:run` is running. Final outcomes will be recorded before merge. - Independent source review passed. Replacing the provider with the original source fails all 8 new phase/timing assertions. Replacing the host boundary with the original source fails all 3 owned diagnostic cases while 7 negative cases remain clean. - CI passed: 54 successful checks and 2 expected Storybook skips. This includes all server, workspace, serialized-server, Runner, browser, build, canary, and real-Sentry SDK lanes. - Greptile scored the committed source 5/5 with no review threads. - No live provider operation, task replay, runtime configuration change, or deployment was performed. ## Risks Low risk: this only adds optional diagnostic evidence. The first observed failing phase does not prove an upstream cause. Elapsed time ends at that observation, not at completion of later cleanup. Evidence is process-local and accompanies immediate failure reporting; post-restart reports cannot reconstruct it. No historical run or lease is backfilled. The wire schema remains v1 and cleanup ownership parsing is unchanged. ## Model Used OpenAI Codex, GPT-6, with repository inspection, code editing, local execution, and independent agent review. The exact model snapshot and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
333 lines
17 KiB
TypeScript
333 lines
17 KiB
TypeScript
import { readWorkspaceBaseRefDiagnostic } from "./workspace-base-ref-diagnostics.js";
|
|
import { getEnvironmentAcquisitionDiagnostic } from "./environment-acquisition-diagnostics.js";
|
|
import type { heartbeatRuns } from "@paperclipai/db";
|
|
import { readRunCancellation } from "./run-cancellation.js";
|
|
import { readProcessLossDiagnostic } from "./process-loss-diagnostics.js";
|
|
import { WORKSPACE_RESTORE_FAILURE_CODES } from "@paperclipai/shared";
|
|
import { redactDiagnosticText } from "@paperclipai/adapter-utils/command-redaction";
|
|
import { sanitizeWorkspaceRestoreDiagnostic } from "@paperclipai/adapter-utils/workspace-restore-diagnostics";
|
|
import { redactCurrentUserText } from "../log-redaction.js";
|
|
import { redactSensitiveText, REDACTED_EVENT_VALUE } from "../redaction.js";
|
|
import { readNativeModelRejectionDiagnostic } from "./native-runtime/native-provider-failure.js";
|
|
import { MANAGED_GIT_WORKTREE_REASON_CODES, PERSISTED_WORKSPACE_SOURCE_REASON_CODES, readManagedGitInspectionDiagnostic } from "./workspace-validation-diagnostics.js";
|
|
|
|
type Run = typeof heartbeatRuns.$inferSelect;
|
|
type Context = Record<string, string | number | boolean>;
|
|
|
|
export interface RunFailureReportOptions {
|
|
/** The caught exception, before callers flatten it to a message. */
|
|
error?: unknown;
|
|
/** Structured adapter error metadata; only known diagnostic keys are read. */
|
|
adapterErrorMeta?: unknown;
|
|
phase?: "setup" | "execute";
|
|
/** Runtime-only values to redact; never included in the captured event. */
|
|
secretValues?: readonly string[];
|
|
}
|
|
|
|
export interface RunFailureException {
|
|
name?: string;
|
|
message?: string;
|
|
stack?: string;
|
|
code?: string;
|
|
status?: number;
|
|
requestId?: string;
|
|
}
|
|
|
|
export interface RunFailureDiagnostics {
|
|
execution: Context;
|
|
adapter: Context;
|
|
provider: Context;
|
|
exceptions: RunFailureException[];
|
|
truncatedFields: string[];
|
|
}
|
|
|
|
function read(value: unknown, key: string): unknown {
|
|
if (!value || typeof value !== "object") return undefined;
|
|
try {
|
|
return (value as Record<string, unknown>)[key];
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function scalars(value: unknown, fields: readonly string[]): Context {
|
|
const result: Context = {};
|
|
for (const field of fields) {
|
|
const entry = read(value, field);
|
|
if (
|
|
typeof entry === "string" || typeof entry === "boolean" ||
|
|
(typeof entry === "number" && Number.isFinite(entry))
|
|
) result[field] = entry;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// Unknown inherited values may contain credentials under arbitrary names.
|
|
// Explicitly configured values remain private, including overrides of these keys.
|
|
const PUBLIC_ENV_KEYS = new Set([
|
|
"PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "COMSPEC", "HOME", "USERPROFILE",
|
|
"HOMEDRIVE", "HOMEPATH", "USER", "USERNAME", "LOGNAME", "SHELL", "LANG",
|
|
"LANGUAGE", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "TEMP", "TMP", "NODE_ENV",
|
|
"XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_DATA_HOME",
|
|
"PWD", "OLDPWD", "INIT_CWD", "SHLVL", "_", "TERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION", "COLORTERM",
|
|
"NPM_LIFECYCLE_EVENT", "NPM_COMMAND", "NPM_EXEC_PATH", "NPM_EXECPATH", "NPM_NODE_EXECPATH",
|
|
"NPM_PACKAGE_NAME", "NPM_PACKAGE_VERSION", "NPM_PACKAGE_JSON", "NPM_CONFIG_USER_AGENT",
|
|
"XPC_SERVICE_NAME", "VITEST_POOL_ID", "VITEST_WORKER_ID",
|
|
"PAPERCLIP_AGENT_ID", "PAPERCLIP_COMPANY_ID", "PAPERCLIP_RUN_ID", "PAPERCLIP_TASK_ID",
|
|
]);
|
|
const PUBLIC_BOOLEAN_ENV_KEYS = new Set([
|
|
"OPENCODE_ALLOW_ALL_MODELS", "CLAUDE_CODE_USE_BEDROCK", "GOOGLE_GENAI_USE_GCA",
|
|
"CI", "NO_COLOR", "FORCE_COLOR",
|
|
"VITEST", "MALLOCNANOZONE", "CODEX_CI", "CODEX_SHELL", "CODEX_SAGE_BACKFILL_TRACKER_TAB_REUSE",
|
|
"DEV", "PROD", "SSR", "PAPERCLIP_REQUIRE_SENTRY_TEST_SDK",
|
|
"PAPERCLIP_DB_BACKUP_ENABLED", "PAPERCLIP_AUTH_DISABLE_SIGN_UP", "PAPERCLIP_ENABLE_COMPANY_DELETION",
|
|
"PAPERCLIP_STORAGE_S3_FORCE_PATH_STYLE", "PAPERCLIP_SECRETS_STRICT_MODE",
|
|
"SERVE_UI", "PAPERCLIP_UI_DEV_MIDDLEWARE", "HEARTBEAT_SCHEDULER_ENABLED", "PAPERCLIP_ANNOUNCEMENTS_ENABLED",
|
|
"GITHUB_ACTIONS", "GITHUB_REF_PROTECTED", "RUNNER_DEBUG", "ACTIONS_RUNNER_DEBUG", "ACTIONS_STEP_DEBUG",
|
|
]);
|
|
const PUBLIC_NUMBER_ENV_KEYS = new Set([
|
|
"PORT", "PAPERCLIP_DB_BACKUP_INTERVAL_MINUTES", "PAPERCLIP_DB_BACKUP_RETENTION_DAYS",
|
|
"PAPERCLIP_WORKSPACE_REAPER_COOLDOWN_DAYS", "HEARTBEAT_SCHEDULER_INTERVAL_MS",
|
|
"GITHUB_RUN_ATTEMPT", "GITHUB_RUN_NUMBER", "GITHUB_RUN_ID", "GITHUB_RETENTION_DAYS",
|
|
]);
|
|
|
|
/** Include declared bindings and unknown values, not just credential-like keys. */
|
|
export function collectRunFailureSecretValues(
|
|
env: unknown, secretKeys: Iterable<string> = [], inherited = false,
|
|
): string[] {
|
|
if (!env || typeof env !== "object") return [];
|
|
const strings = Object.fromEntries(Object.entries(env).filter(
|
|
(entry): entry is [string, string] => typeof entry[1] === "string",
|
|
));
|
|
const declared = new Set(secretKeys);
|
|
const values: string[] = [];
|
|
for (const [key, value] of Object.entries(strings)) {
|
|
if (!value) continue;
|
|
const publicSetting = (inherited && (
|
|
PUBLIC_ENV_KEYS.has(key.toUpperCase()) ||
|
|
(PUBLIC_NUMBER_ENV_KEYS.has(key.toUpperCase()) && /^\d+$/.test(value)) ||
|
|
(key === "BASE_URL" && value === "/") ||
|
|
(key === "MODE" && /^(?:test|development|production)$/.test(value))
|
|
)) ||
|
|
(PUBLIC_BOOLEAN_ENV_KEYS.has(key.toUpperCase()) && /^(?:0|1|true|false)$/i.test(value));
|
|
if (declared.has(key) || !publicSetting) values.push(value);
|
|
try {
|
|
const password = new URL(value).password;
|
|
if (password) values.push(value, password, decodeURIComponent(password));
|
|
} catch { /* ordinary environment values are not URLs */ }
|
|
}
|
|
return [...new Set(values)].sort((a, b) => b.length - a.length);
|
|
}
|
|
|
|
/** Redact literal, JSON-escaped and URI-encoded forms without rewriting markers. */
|
|
export function redactRunFailureSecretValues<T>(input: T, values: readonly string[]): T {
|
|
const forms = [...new Set(values.filter(Boolean).flatMap((value) => {
|
|
const forms = [value, JSON.stringify(value).slice(1, -1)];
|
|
try { forms.push(encodeURIComponent(value)); } catch { /* malformed Unicode */ }
|
|
return forms;
|
|
}))].sort((a, b) => b.length - a.length);
|
|
if (forms.length === 0) return input;
|
|
const pattern = new RegExp(forms.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "gu");
|
|
const redact = (value: unknown): unknown => {
|
|
if (typeof value === "string") return value.replace(pattern, () => REDACTED_EVENT_VALUE);
|
|
if (Array.isArray(value)) return value.map(redact);
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, redact(entry)]));
|
|
}
|
|
return value;
|
|
};
|
|
return redact(input) as T;
|
|
}
|
|
|
|
/** Snapshot only diagnostics. Never walk a request, response, config or prompt. */
|
|
export function collectRunFailureDiagnostics(run: Run, options: RunFailureReportOptions): RunFailureDiagnostics {
|
|
const execution = scalars(run, [
|
|
"runtimeMode", "executionStage", "nativePhase", "driverKind", "driverVersion",
|
|
]);
|
|
if (options.phase) execution.failurePhase = options.phase;
|
|
if (run.startedAt && run.finishedAt) {
|
|
const durationMs = run.finishedAt.getTime() - run.startedAt.getTime();
|
|
if (Number.isFinite(durationMs) && durationMs >= 0) execution.durationMs = durationMs;
|
|
}
|
|
const result = run.resultJson;
|
|
const configuration = read(result, "configurationIncomplete");
|
|
if (run.errorCode === "configuration_incomplete" && read(configuration, "reason") === "workspace_base_ref_unresolved") {
|
|
const diagnostic = readWorkspaceBaseRefDiagnostic(read(configuration, "baseRefDiagnostic"));
|
|
if (diagnostic) {
|
|
for (const [key, value] of Object.entries(diagnostic)) {
|
|
if (key !== "schemaVersion") execution[`workspaceBaseRef${key[0]!.toUpperCase()}${key.slice(1)}`] = value;
|
|
}
|
|
}
|
|
}
|
|
const workspaceValidation = read(result, "workspaceValidation");
|
|
if (run.errorCode === "workspace_validation_failed" && read(workspaceValidation, "reason") === "git_worktree_not_reusable") {
|
|
execution.workspaceValidationReason = "git_worktree_not_reusable";
|
|
const reasonCode = MANAGED_GIT_WORKTREE_REASON_CODES.find(code => code === read(workspaceValidation, "reasonCode"));
|
|
if (reasonCode) execution.workspaceValidationReasonCode = reasonCode;
|
|
const diagnostic = reasonCode === "git_inspection_failed"
|
|
? readManagedGitInspectionDiagnostic(read(workspaceValidation, "inspectionDiagnostic")) : null;
|
|
if (diagnostic) {
|
|
execution.workspaceValidationInspectionCommand = diagnostic.command;
|
|
execution.workspaceValidationInspectionFailure = diagnostic.failure;
|
|
if (diagnostic.errorCode) execution.workspaceValidationInspectionErrorCode = diagnostic.errorCode;
|
|
if (diagnostic.exitCode !== undefined) execution.workspaceValidationInspectionExitCode = diagnostic.exitCode;
|
|
}
|
|
}
|
|
if (run.errorCode === "workspace_validation_failed" && read(workspaceValidation, "reason") === "persisted_workspace_source_conflict") {
|
|
execution.workspaceValidationReason = "persisted_workspace_source_conflict";
|
|
const reasonCode = PERSISTED_WORKSPACE_SOURCE_REASON_CODES.find(code => code === read(workspaceValidation, "reasonCode"));
|
|
if (reasonCode) execution.workspaceValidationReasonCode = reasonCode;
|
|
}
|
|
if (run.errorCode === "process_lost") {
|
|
const diagnostic = readProcessLossDiagnostic(read(result, "processLossDiagnostic"));
|
|
for (const [field, value] of Object.entries(diagnostic)) {
|
|
execution[`processLoss${field[0]!.toUpperCase()}${field.slice(1)}`] = value;
|
|
}
|
|
}
|
|
const cancellation = readRunCancellation(result);
|
|
if (cancellation) {
|
|
execution.cancellationSource = cancellation.source;
|
|
execution.cancellationExpected = cancellation.expected;
|
|
execution.cancellationInitiatorType = cancellation.initiator.type;
|
|
} else if (run.status === "cancelled") {
|
|
execution.cancellationSource = "unknown";
|
|
execution.cancellationExpected = false;
|
|
}
|
|
Object.assign(execution, scalars(result, [
|
|
"mode", "stopReason", "timeoutFired", "timeoutSource", "timeoutConfigured",
|
|
"effectiveTimeoutSec", "errorFamily",
|
|
]));
|
|
for (const field of ["acpLastEventAgeMs", "acpObservedEventCount", "acpPendingToolCount"]) {
|
|
const value = read(result, field);
|
|
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) execution[field] = value;
|
|
}
|
|
const toolInventoryComplete = read(result, "acpToolInventoryComplete");
|
|
if (typeof toolInventoryComplete === "boolean") execution.acpToolInventoryComplete = toolInventoryComplete;
|
|
const restoreFailure = read(result, "workspaceRestoreFailure");
|
|
const restoreCode = WORKSPACE_RESTORE_FAILURE_CODES.find((code) => code === restoreFailure);
|
|
if (restoreCode) {
|
|
execution.workspaceRestoreFailure = restoreCode;
|
|
const diagnostic = sanitizeWorkspaceRestoreDiagnostic(read(result, "workspaceRestoreDiagnostic"));
|
|
if (diagnostic) {
|
|
execution.workspaceRestorePhase = diagnostic.phase;
|
|
execution.workspaceRestoreErrorCode = diagnostic.errorCode;
|
|
if (diagnostic.step) execution.workspaceRestoreStep = diagnostic.step;
|
|
if (diagnostic.httpStatus !== undefined) execution.workspaceRestoreHttpStatus = diagnostic.httpStatus;
|
|
if (diagnostic.exitCode !== undefined) execution.workspaceRestoreExitCode = diagnostic.exitCode;
|
|
if (diagnostic.transferStep) execution.workspaceRestoreTransferStep = diagnostic.transferStep;
|
|
if (diagnostic.transferFailureKind) execution.workspaceRestoreTransferFailureKind = diagnostic.transferFailureKind;
|
|
if (diagnostic.rpcCode !== undefined) execution.workspaceRestoreRpcCode = diagnostic.rpcCode;
|
|
if (diagnostic.gitCommand) execution.workspaceRestoreGitCommand = diagnostic.gitCommand;
|
|
if (diagnostic.gitFailureKind) execution.workspaceRestoreGitFailureKind = diagnostic.gitFailureKind;
|
|
}
|
|
}
|
|
const adapter = scalars(options.adapterErrorMeta, [
|
|
"category", "phase", "errorName", "acpCode", "causeMessage", "retryable",
|
|
"stackPreview", "status", "statusCode", "requestId",
|
|
]);
|
|
const provider = scalars(read(result, "terminalSessionFailure"), ["category", "title", "details"]);
|
|
const nativeProviderFailure = readNativeModelRejectionDiagnostic(read(result, "nativeProviderFailure"));
|
|
if (nativeProviderFailure) Object.assign(provider, nativeProviderFailure);
|
|
const truncatedFields: string[] = [];
|
|
const providerTruncation = read(read(result, "terminalSessionFailure"), "truncatedFields");
|
|
if (Array.isArray(providerTruncation)) {
|
|
for (const field of ["title", "details"]) {
|
|
if (providerTruncation.includes(field)) truncatedFields.push(`provider.${field}`);
|
|
}
|
|
}
|
|
const exceptions: RunFailureException[] = [];
|
|
const seen = new Set<unknown>();
|
|
let error = options.error;
|
|
while (error !== undefined && error !== null && exceptions.length < 4) {
|
|
if (seen.has(error)) {
|
|
truncatedFields.push("exceptions.cycle");
|
|
break;
|
|
}
|
|
seen.add(error);
|
|
if (typeof error === "string") {
|
|
exceptions.push({ message: error });
|
|
error = undefined;
|
|
break;
|
|
}
|
|
if (typeof error !== "object") break;
|
|
const acquisition = options.phase === "setup" && run.errorCode === "setup_failed"
|
|
? getEnvironmentAcquisitionDiagnostic(error, run) : null;
|
|
if (acquisition && execution.environmentAcquisitionPhase === undefined) {
|
|
execution.environmentAcquisitionPhase = acquisition.phase;
|
|
execution.environmentAcquisitionElapsedMs = acquisition.elapsedMs;
|
|
execution.environmentAcquisitionBudgetMs = acquisition.budgetMs;
|
|
}
|
|
if (execution.restoreLockOwnerState === undefined && read(error, "code") === "ERR_WORKSPACE_RESTORE_LOCK_TIMEOUT") {
|
|
const lock = read(error, "workspaceRestoreLock");
|
|
const operation = read(lock, "operation");
|
|
if (["agent_directory_prepare", "agent_directory_release", "agent_directory_collect", "agent_directory_checkpoint", "agent_directory_handoff"].some(value => value === operation)) {
|
|
execution.restoreLockOperation = operation as string;
|
|
}
|
|
const ownerState = read(lock, "ownerState");
|
|
if (["alive", "dead", "unknown", "missing", "invalid"].some(value => value === ownerState)) {
|
|
execution.restoreLockOwnerState = ownerState as string;
|
|
}
|
|
for (const field of ["knownLocalHolder", "ownerSameProcess", "ownerPredatesProcess"]) {
|
|
const value = read(lock, field);
|
|
if (typeof value === "boolean") execution[`restoreLock${field[0]!.toUpperCase()}${field.slice(1)}`] = value;
|
|
}
|
|
for (const field of ["ownerAgeMs", "waitMs"]) {
|
|
const value = read(lock, field);
|
|
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= 7 * 24 * 60 * 60 * 1000) {
|
|
execution[`restoreLock${field[0]!.toUpperCase()}${field.slice(1)}`] = value;
|
|
}
|
|
}
|
|
}
|
|
const entry: RunFailureException = {};
|
|
for (const field of ["name", "message", "stack", "code"] as const) {
|
|
const value = read(error, field);
|
|
if (typeof value === "string") entry[field] = value;
|
|
else if (field === "code" && typeof value === "number" && Number.isFinite(value)) entry.code = String(value);
|
|
}
|
|
const status = read(error, "status") ?? read(error, "statusCode");
|
|
if (typeof status === "number" && Number.isInteger(status) && status >= 100 && status <= 599) entry.status = status;
|
|
const requestId = read(error, "requestId") ?? read(error, "request_id");
|
|
if (typeof requestId === "string") entry.requestId = requestId;
|
|
if (Object.keys(entry).length > 0) exceptions.push(entry);
|
|
else break;
|
|
error = read(error, "cause");
|
|
}
|
|
if (error !== undefined && error !== null && exceptions.length === 4) truncatedFields.push("exceptions.depth");
|
|
return { execution, adapter, provider, exceptions, truncatedFields };
|
|
}
|
|
|
|
/** Redact complete values before cutting, including credentials across a cut. */
|
|
export function sanitizeRunFailureText(input: string, limit: number): string {
|
|
const normalized = input
|
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
|
|
.replace(/[\ud800-\udfff]/gu, "\ufffd");
|
|
const clean = redactSensitiveText(redactDiagnosticText(redactCurrentUserText(normalized)));
|
|
if (clean.length <= limit) return clean;
|
|
const suffix = "\n[truncated]";
|
|
let end = limit - suffix.length;
|
|
if ((clean.codePointAt(end - 1) ?? 0) > 0xffff) end--;
|
|
return clean.slice(0, end) + suffix;
|
|
}
|
|
|
|
/** Called after the run's registered secret values have also been removed. */
|
|
export function sanitizeRunFailureDiagnostics(raw: RunFailureDiagnostics): RunFailureDiagnostics {
|
|
const truncatedFields = [...raw.truncatedFields];
|
|
const text = (value: string, path: string, limit: number) => {
|
|
const sanitized = sanitizeRunFailureText(value, limit);
|
|
if (sanitized.endsWith("\n[truncated]")) truncatedFields.push(path);
|
|
return sanitized;
|
|
};
|
|
const context = (value: Context, prefix: string, limits: Record<string, number> = {}): Context =>
|
|
Object.fromEntries(Object.entries(value).map(([key, value]) => [key,
|
|
typeof value === "string" ? text(value, `${prefix}.${key}`, limits[key] ?? 200) : value,
|
|
]));
|
|
return {
|
|
execution: context(raw.execution, "execution"),
|
|
adapter: context(raw.adapter, "adapter", { causeMessage: 2048, stackPreview: 8192 }),
|
|
provider: context(raw.provider, "provider", { title: 4096, details: 12288 }),
|
|
exceptions: raw.exceptions.map((entry, i) => context(entry as Context, `exceptions.${i}`, { message: 2048, stack: 8192 })),
|
|
truncatedFields: [...new Set(truncatedFields)],
|
|
};
|
|
}
|