diff --git a/build/api/index.js b/build/api/index.js index ca974bedf..02582bbcd 100644 --- a/build/api/index.js +++ b/build/api/index.js @@ -5171,6 +5171,9 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'workspace-changes-rejected', 'closed-issue-reconciliation-failed', 'approval-evidence-read-failed', + 'stored-configuration-schema-missing', + 'stored-configuration-schema-unsupported', + 'stored-configuration-invalid', ]); const PRESERVED_STATE = 'Existing persisted state and completed external effects were preserved.'; const UNCHANGED_STATE = 'No new state or external effect was created.'; @@ -5362,6 +5365,9 @@ class ApplicationError extends Error { exports.ApplicationError = ApplicationError; _ApplicationError_cause = new WeakMap(); const RECOVERY_VARIABLE_KEYS = Object.freeze({ + 'stored-configuration-schema-missing': Object.freeze(['issueNumber', 'expectedVersion']), + 'stored-configuration-schema-unsupported': Object.freeze(['issueNumber', 'expectedVersion', 'actualVersion']), + 'stored-configuration-invalid': Object.freeze(['issueNumber', 'expectedVersion']), 'closed-issue-reconciliation-failed': Object.freeze(['resultId', 'phase', 'state', 'reason', 'changed', 'remaining']), 'approval-evidence-read-failed': Object.freeze(['operation', 'reason', 'permission']), 'pull-request-link-restored': Object.freeze([]), @@ -5391,6 +5397,13 @@ function normalizeApplicationErrorRecovery(recovery) { || actualKeys.some((key, index) => key !== expectedKeys[index])) { throw new TypeError(`Application error recovery variables are invalid for ${recovery.id}.`); } + if (recovery.id.startsWith('stored-configuration-') + && (![variables.issueNumber, variables.expectedVersion].every(value => typeof value === 'number' && Number.isSafeInteger(value) && value > 0) + || (recovery.id === 'stored-configuration-schema-unsupported' + && !(typeof variables.actualVersion === 'number' + && Number.isSafeInteger(variables.actualVersion) && variables.actualVersion >= 0)))) { + throw new TypeError('Stored configuration diagnostic is invalid.'); + } if (recovery.id === 'managed-branch-enrichment-failed' && (typeof variables.branchName !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/u.test(variables.branchName))) { diff --git a/build/api/src/data/model/application_error.d.ts b/build/api/src/data/model/application_error.d.ts index bd017218f..63bbe93cb 100644 --- a/build/api/src/data/model/application_error.d.ts +++ b/build/api/src/data/model/application_error.d.ts @@ -3,9 +3,22 @@ import { type ApprovalEvidenceDiagnostic } from '../../domain/approval_evidence_ import { type BranchSyncValidationCode } from '../../domain/branch_sync_validation'; export type ApplicationErrorKind = 'configuration' | 'authorization' | 'provider' | 'agent' | 'validation' | 'workflow' | 'unknown'; export type ApplicationErrorCode = 'configuration.invalid' | 'configuration.unsupported' | 'authorization.denied' | 'authorization.credential-invalid' | 'provider.not-found' | 'provider.conflict' | 'provider.rate-limited' | 'provider.unavailable' | 'provider.contract-invalid' | 'agent.policy-rejected' | 'agent.failed' | 'locale.output-invalid' | 'locale.translation-failed' | 'validation.invalid-input' | 'validation.evidence-too-large' | 'validation.evidence-unavailable' | 'workflow.invalid-event' | 'workflow.stale' | 'workflow.cancelled' | 'workflow.failed' | 'workflow.presentation-pending' | 'timeout' | 'unexpected'; -export declare const APPLICATION_ERROR_RECOVERY_IDS: readonly ["pull-request-link-restored", "pull-request-link-base-retained", "pull-request-link-reference-retained", "pull-request-link-base-and-reference-retained", "managed-branch-enrichment-failed", "inactivity-explanation-failed", "bugbot-review-blocks-pending", "branch-sync-agent-disabled", "branch-sync-fixer-unavailable", "branch-sync-resolution-invalid", "sdd-draft-size-exceeded", "sdd-draft-format-invalid", "workspace-changes-rejected", "closed-issue-reconciliation-failed", "approval-evidence-read-failed"]; +export declare const APPLICATION_ERROR_RECOVERY_IDS: readonly ["pull-request-link-restored", "pull-request-link-base-retained", "pull-request-link-reference-retained", "pull-request-link-base-and-reference-retained", "managed-branch-enrichment-failed", "inactivity-explanation-failed", "bugbot-review-blocks-pending", "branch-sync-agent-disabled", "branch-sync-fixer-unavailable", "branch-sync-resolution-invalid", "sdd-draft-size-exceeded", "sdd-draft-format-invalid", "workspace-changes-rejected", "closed-issue-reconciliation-failed", "approval-evidence-read-failed", "stored-configuration-schema-missing", "stored-configuration-schema-unsupported", "stored-configuration-invalid"]; export type ApplicationErrorRecoveryId = typeof APPLICATION_ERROR_RECOVERY_IDS[number]; interface ApplicationErrorRecoveryVariables { + readonly 'stored-configuration-schema-missing': Readonly<{ + issueNumber: number; + expectedVersion: number; + }>; + readonly 'stored-configuration-schema-unsupported': Readonly<{ + issueNumber: number; + expectedVersion: number; + actualVersion: number; + }>; + readonly 'stored-configuration-invalid': Readonly<{ + issueNumber: number; + expectedVersion: number; + }>; readonly 'closed-issue-reconciliation-failed': ClosedIssueReconciliationFacts; readonly 'approval-evidence-read-failed': ApprovalEvidenceDiagnostic; readonly 'pull-request-link-restored': Readonly>; diff --git a/build/cli/index.js b/build/cli/index.js index c85895d11..addfe08bd 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -39313,7 +39313,26 @@ const SPANISH_CONTENT = Object.freeze({ retainedState: PRESERVED_STATE_ES, }), }); +const STORED_CONFIGURATION_ACTION_EN = 'A maintainer must review {recoveryGuide}. Preserve a copy before removing only a verified obsolete unmanaged block. If managed branch, release, hotfix or orchestration state may exist, stop and restore verified supported state with a maintainer. Do not just add a schema version or delete managed state. Correct the configuration before rerunning; retrying unchanged state will fail again.'; +const STORED_CONFIGURATION_ACTION_ES = 'Un mantenedor debe revisar {recoveryGuide}. Conserva una copia antes de retirar solo un bloque obsoleto confirmado como no gestionado. Si puede existir estado gestionado de ramas, releases, hotfixes u orquestación, detente y restaura estado compatible verificado con un mantenedor. No añadas simplemente una versión de esquema ni borres estado gestionado. Corrige la configuración antes de reejecutar; el mismo estado volverá a fallar.'; +const STORED_CONFIGURATION_RETAINED_EN = 'The issue/PR description and stored configuration were not changed by this read.'; +const STORED_CONFIGURATION_RETAINED_ES = 'Esta lectura no modificó la descripción de la issue/PR ni su configuración almacenada.'; const ENGLISH_RECOVERY_CONTENT = Object.freeze({ + 'stored-configuration-schema-missing': Object.freeze({ + impact: 'Automation stopped for issue/PR #{issueNumber}: the stored configuration has no schema version; version {expectedVersion} is required.', + action: STORED_CONFIGURATION_ACTION_EN, + retainedState: STORED_CONFIGURATION_RETAINED_EN, + }), + 'stored-configuration-schema-unsupported': Object.freeze({ + impact: 'Automation stopped for issue/PR #{issueNumber}: stored schema version {actualVersion} is unsupported; version {expectedVersion} is required.', + action: STORED_CONFIGURATION_ACTION_EN, + retainedState: STORED_CONFIGURATION_RETAINED_EN, + }), + 'stored-configuration-invalid': Object.freeze({ + impact: 'Automation stopped for issue/PR #{issueNumber}: the stored configuration, markers or schema value are malformed; valid schema version {expectedVersion} is required.', + action: STORED_CONFIGURATION_ACTION_EN, + retainedState: STORED_CONFIGURATION_RETAINED_EN, + }), 'closed-issue-reconciliation-failed': Object.freeze({ impact: 'Issue cleanup remains unverified: {resultId}, phase {phase}; native state {state}, reason {reason}.', action: 'Inspect the closed-issue or merged-PR run and current native state. Retry cleanup only if the issue is still closed with the intended reason; leave reopened work open. Resolve the named failure before retrying.', @@ -39391,6 +39410,21 @@ const ENGLISH_RECOVERY_CONTENT = Object.freeze({ }), }); const SPANISH_RECOVERY_CONTENT = Object.freeze({ + 'stored-configuration-schema-missing': Object.freeze({ + impact: 'La automatización se detuvo para la issue/PR #{issueNumber}: la configuración almacenada no tiene versión de esquema; se requiere la versión {expectedVersion}.', + action: STORED_CONFIGURATION_ACTION_ES, + retainedState: STORED_CONFIGURATION_RETAINED_ES, + }), + 'stored-configuration-schema-unsupported': Object.freeze({ + impact: 'La automatización se detuvo para la issue/PR #{issueNumber}: la versión de esquema almacenada {actualVersion} no es compatible; se requiere la versión {expectedVersion}.', + action: STORED_CONFIGURATION_ACTION_ES, + retainedState: STORED_CONFIGURATION_RETAINED_ES, + }), + 'stored-configuration-invalid': Object.freeze({ + impact: 'La automatización se detuvo para la issue/PR #{issueNumber}: la configuración almacenada, sus delimitadores o el valor del esquema no son válidos; se requiere un esquema válido de versión {expectedVersion}.', + action: STORED_CONFIGURATION_ACTION_ES, + retainedState: STORED_CONFIGURATION_RETAINED_ES, + }), 'closed-issue-reconciliation-failed': Object.freeze({ impact: 'La limpieza de la issue sigue sin verificarse: {resultId}, fase {phase}; estado nativo {state}, motivo {reason}.', action: 'Revisa la ejecución de cierre o de la PR fusionada y el estado nativo actual. Reintenta la limpieza solo si la issue sigue cerrada con el motivo previsto; conserva abierto el trabajo reabierto. Resuelve el fallo indicado antes de reintentar.', @@ -39520,12 +39554,16 @@ exports.buildApplicationErrorPresentation = buildApplicationErrorPresentation; exports.renderApplicationErrorText = renderApplicationErrorText; exports.renderApplicationErrorMarkdown = renderApplicationErrorMarkdown; const application_error_message_catalog_1 = __nccwpck_require__(64809); +// This trusted destination is inserted after localization, never supplied by a body or translation. +const STORED_CONFIGURATION_RECOVERY_URL = 'https://docs.page/vypdev/copilot/security-operations/operations/troubleshooting#unsupported-stored-configuration'; /** Shared semantic view model for terminal, GitHub, and API presentation. */ function buildApplicationErrorPresentation(error, message = application_error_message_catalog_1.readEnglishApplicationErrorMessage) { const descriptor = error.recovery ? `error.recovery.${error.recovery.id}` : `error.${error.code}`; - const variables = error.recovery?.variables; + const variables = error.recovery?.id.startsWith('stored-configuration-') + ? { ...error.recovery.variables, recoveryGuide: STORED_CONFIGURATION_RECOVERY_URL } + : error.recovery?.variables; return { impact: message(`${descriptor}.impact`, variables), code: error.code, @@ -78373,6 +78411,9 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'workspace-changes-rejected', 'closed-issue-reconciliation-failed', 'approval-evidence-read-failed', + 'stored-configuration-schema-missing', + 'stored-configuration-schema-unsupported', + 'stored-configuration-invalid', ]); const PRESERVED_STATE = 'Existing persisted state and completed external effects were preserved.'; const UNCHANGED_STATE = 'No new state or external effect was created.'; @@ -78564,6 +78605,9 @@ class ApplicationError extends Error { exports.ApplicationError = ApplicationError; _ApplicationError_cause = new WeakMap(); const RECOVERY_VARIABLE_KEYS = Object.freeze({ + 'stored-configuration-schema-missing': Object.freeze(['issueNumber', 'expectedVersion']), + 'stored-configuration-schema-unsupported': Object.freeze(['issueNumber', 'expectedVersion', 'actualVersion']), + 'stored-configuration-invalid': Object.freeze(['issueNumber', 'expectedVersion']), 'closed-issue-reconciliation-failed': Object.freeze(['resultId', 'phase', 'state', 'reason', 'changed', 'remaining']), 'approval-evidence-read-failed': Object.freeze(['operation', 'reason', 'permission']), 'pull-request-link-restored': Object.freeze([]), @@ -78593,6 +78637,13 @@ function normalizeApplicationErrorRecovery(recovery) { || actualKeys.some((key, index) => key !== expectedKeys[index])) { throw new TypeError(`Application error recovery variables are invalid for ${recovery.id}.`); } + if (recovery.id.startsWith('stored-configuration-') + && (![variables.issueNumber, variables.expectedVersion].every(value => typeof value === 'number' && Number.isSafeInteger(value) && value > 0) + || (recovery.id === 'stored-configuration-schema-unsupported' + && !(typeof variables.actualVersion === 'number' + && Number.isSafeInteger(variables.actualVersion) && variables.actualVersion >= 0)))) { + throw new TypeError('Stored configuration diagnostic is invalid.'); + } if (recovery.id === 'managed-branch-enrichment-failed' && (typeof variables.branchName !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/u.test(variables.branchName))) { @@ -83899,6 +83950,7 @@ function combineOrganizationAccess(secrets, variables) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.isGithubAlreadyExists = exports.isGithubPermissionDenied = exports.isGithubRateLimited = exports.isGithubNotFound = exports.getGithubErrorStatus = void 0; +exports.githubReadErrorCode = githubReadErrorCode; const getGithubErrorStatus = (error) => { if (typeof error !== "object" || error === null) return undefined; @@ -83908,6 +83960,23 @@ const getGithubErrorStatus = (error) => { exports.getGithubErrorStatus = getGithubErrorStatus; const isGithubNotFound = (error) => (0, exports.getGithubErrorStatus)(error) === 404; exports.isGithubNotFound = isGithubNotFound; +/** Maps failed metadata reads without copying untrusted provider text. */ +function githubReadErrorCode(error) { + const status = (0, exports.getGithubErrorStatus)(error); + if (status === 401) + return 'authorization.credential-invalid'; + if ((0, exports.isGithubRateLimited)(error)) + return 'provider.rate-limited'; + if (status === 403) + return 'authorization.denied'; + if (status === 404) + return 'provider.not-found'; + if (status === 409 || status === 412) + return 'provider.conflict'; + if (status === 400 || status === 422) + return 'provider.contract-invalid'; + return 'provider.unavailable'; +} const isGithubRateLimited = (error) => { const status = (0, exports.getGithubErrorStatus)(error); if (status === 429) @@ -84618,8 +84687,9 @@ class IssueContentRepository { return issue.body ?? ''; } catch (error) { - (0, logger_1.logError)((0, application_error_1.toApplicationError)(error, 'provider.unavailable', `Unable to read issue #${issueNumber} description.`)); - throw error; + const semanticError = (0, application_error_1.toApplicationError)(error, (0, github_error_policy_1.githubReadErrorCode)(error), 'Unable to read the issue or pull-request description.'); + (0, logger_1.logError)(semanticError); + throw semanticError; } }; this.getIssueDescription = async (owner, repository, issueNumber, token) => { @@ -102753,18 +102823,44 @@ class ConfigurationHandler extends issue_content_interface_1.IssueContentInterfa return this.internalUpdate(query, (0, configuration_payload_policy_1.buildConfigurationPayload)(context, storedRaw)); }; this.get = async (query) => { + let description; + try { + description = await this.issueDescriptionPort.getDescription(query.owner, query.repository, query.issueNumber, query.token); + } + catch (cause) { + const semanticError = (0, application_error_1.toApplicationError)(cause, 'provider.unavailable', 'Unable to read issue configuration.'); + (0, logger_1.logError)(semanticError); + throw semanticError; + } + const variables = { issueNumber: query.issueNumber, expectedVersion: config_1.CONFIG_SCHEMA_VERSION }; + let recovery = { id: 'stored-configuration-invalid', variables }; try { - const description = await this.issueDescriptionPort.getDescription(query.owner, query.repository, query.issueNumber, query.token); const config = this.getContent(description); if (config === undefined) { + if (description?.includes('')) { + throw new Error('Stored configuration markers are inconsistent.'); + } return undefined; } - const branchConfig = (0, config_1.requireCurrentConfigurationPayload)(JSON.parse(config)); + const parsed = JSON.parse(config); + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { + const version = parsed.schemaVersion; + if (version === undefined) { + recovery = { id: 'stored-configuration-schema-missing', variables }; + } + else if (typeof version === 'number' && Number.isSafeInteger(version) + && version >= 0 && version !== config_1.CONFIG_SCHEMA_VERSION) { + recovery = { id: 'stored-configuration-schema-unsupported', variables: { ...variables, actualVersion: version } }; + } + } + const branchConfig = (0, config_1.requireCurrentConfigurationPayload)(parsed); return new config_1.Config(branchConfig); } - catch (error) { - (0, logger_1.logError)((0, application_error_1.toApplicationError)(error, 'configuration.invalid', 'Unable to read issue configuration.')); - throw error; + catch (cause) { + const semanticError = (0, application_error_1.toApplicationError)(cause, 'configuration.invalid', 'Stored issue/PR configuration is invalid.', { recovery }); + (0, logger_1.logError)(semanticError); + throw semanticError; } }; } diff --git a/build/github_action/index.js b/build/github_action/index.js index fc8a49c6b..9cd6d5ace 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -37081,7 +37081,16 @@ async function runGitHubActionEntry(run = runGitHubAction) { catch (cause) { const semanticError = (0, application_error_1.toApplicationError)(cause, 'workflow.failed', 'GitHub Action execution failed.'); (0, logger_1.logError)(semanticError); - core.setFailed((0, application_error_presentation_policy_1.renderApplicationErrorText)(semanticError, earlyGithubActionErrorMessage())); + const message = earlyGithubActionErrorMessage(); + core.setFailed((0, application_error_presentation_policy_1.renderApplicationErrorText)(semanticError, message)); + if (semanticError.recovery?.id.startsWith('stored-configuration-')) { + try { + await (0, github_action_summary_composition_root_1.createGithubActionSummaryCompositionRoot)().publish((0, application_error_presentation_policy_1.renderApplicationErrorMarkdown)(semanticError, message)); + } + catch (summaryCause) { + (0, logger_1.logError)((0, application_error_1.toApplicationError)(summaryCause, 'provider.unavailable', 'Unable to publish the configuration failure summary.')); + } + } } }); } @@ -40666,7 +40675,26 @@ const SPANISH_CONTENT = Object.freeze({ retainedState: PRESERVED_STATE_ES, }), }); +const STORED_CONFIGURATION_ACTION_EN = 'A maintainer must review {recoveryGuide}. Preserve a copy before removing only a verified obsolete unmanaged block. If managed branch, release, hotfix or orchestration state may exist, stop and restore verified supported state with a maintainer. Do not just add a schema version or delete managed state. Correct the configuration before rerunning; retrying unchanged state will fail again.'; +const STORED_CONFIGURATION_ACTION_ES = 'Un mantenedor debe revisar {recoveryGuide}. Conserva una copia antes de retirar solo un bloque obsoleto confirmado como no gestionado. Si puede existir estado gestionado de ramas, releases, hotfixes u orquestación, detente y restaura estado compatible verificado con un mantenedor. No añadas simplemente una versión de esquema ni borres estado gestionado. Corrige la configuración antes de reejecutar; el mismo estado volverá a fallar.'; +const STORED_CONFIGURATION_RETAINED_EN = 'The issue/PR description and stored configuration were not changed by this read.'; +const STORED_CONFIGURATION_RETAINED_ES = 'Esta lectura no modificó la descripción de la issue/PR ni su configuración almacenada.'; const ENGLISH_RECOVERY_CONTENT = Object.freeze({ + 'stored-configuration-schema-missing': Object.freeze({ + impact: 'Automation stopped for issue/PR #{issueNumber}: the stored configuration has no schema version; version {expectedVersion} is required.', + action: STORED_CONFIGURATION_ACTION_EN, + retainedState: STORED_CONFIGURATION_RETAINED_EN, + }), + 'stored-configuration-schema-unsupported': Object.freeze({ + impact: 'Automation stopped for issue/PR #{issueNumber}: stored schema version {actualVersion} is unsupported; version {expectedVersion} is required.', + action: STORED_CONFIGURATION_ACTION_EN, + retainedState: STORED_CONFIGURATION_RETAINED_EN, + }), + 'stored-configuration-invalid': Object.freeze({ + impact: 'Automation stopped for issue/PR #{issueNumber}: the stored configuration, markers or schema value are malformed; valid schema version {expectedVersion} is required.', + action: STORED_CONFIGURATION_ACTION_EN, + retainedState: STORED_CONFIGURATION_RETAINED_EN, + }), 'closed-issue-reconciliation-failed': Object.freeze({ impact: 'Issue cleanup remains unverified: {resultId}, phase {phase}; native state {state}, reason {reason}.', action: 'Inspect the closed-issue or merged-PR run and current native state. Retry cleanup only if the issue is still closed with the intended reason; leave reopened work open. Resolve the named failure before retrying.', @@ -40744,6 +40772,21 @@ const ENGLISH_RECOVERY_CONTENT = Object.freeze({ }), }); const SPANISH_RECOVERY_CONTENT = Object.freeze({ + 'stored-configuration-schema-missing': Object.freeze({ + impact: 'La automatización se detuvo para la issue/PR #{issueNumber}: la configuración almacenada no tiene versión de esquema; se requiere la versión {expectedVersion}.', + action: STORED_CONFIGURATION_ACTION_ES, + retainedState: STORED_CONFIGURATION_RETAINED_ES, + }), + 'stored-configuration-schema-unsupported': Object.freeze({ + impact: 'La automatización se detuvo para la issue/PR #{issueNumber}: la versión de esquema almacenada {actualVersion} no es compatible; se requiere la versión {expectedVersion}.', + action: STORED_CONFIGURATION_ACTION_ES, + retainedState: STORED_CONFIGURATION_RETAINED_ES, + }), + 'stored-configuration-invalid': Object.freeze({ + impact: 'La automatización se detuvo para la issue/PR #{issueNumber}: la configuración almacenada, sus delimitadores o el valor del esquema no son válidos; se requiere un esquema válido de versión {expectedVersion}.', + action: STORED_CONFIGURATION_ACTION_ES, + retainedState: STORED_CONFIGURATION_RETAINED_ES, + }), 'closed-issue-reconciliation-failed': Object.freeze({ impact: 'La limpieza de la issue sigue sin verificarse: {resultId}, fase {phase}; estado nativo {state}, motivo {reason}.', action: 'Revisa la ejecución de cierre o de la PR fusionada y el estado nativo actual. Reintenta la limpieza solo si la issue sigue cerrada con el motivo previsto; conserva abierto el trabajo reabierto. Resuelve el fallo indicado antes de reintentar.', @@ -40873,12 +40916,16 @@ exports.buildApplicationErrorPresentation = buildApplicationErrorPresentation; exports.renderApplicationErrorText = renderApplicationErrorText; exports.renderApplicationErrorMarkdown = renderApplicationErrorMarkdown; const application_error_message_catalog_1 = __nccwpck_require__(64809); +// This trusted destination is inserted after localization, never supplied by a body or translation. +const STORED_CONFIGURATION_RECOVERY_URL = 'https://docs.page/vypdev/copilot/security-operations/operations/troubleshooting#unsupported-stored-configuration'; /** Shared semantic view model for terminal, GitHub, and API presentation. */ function buildApplicationErrorPresentation(error, message = application_error_message_catalog_1.readEnglishApplicationErrorMessage) { const descriptor = error.recovery ? `error.recovery.${error.recovery.id}` : `error.${error.code}`; - const variables = error.recovery?.variables; + const variables = error.recovery?.id.startsWith('stored-configuration-') + ? { ...error.recovery.variables, recoveryGuide: STORED_CONFIGURATION_RECOVERY_URL } + : error.recovery?.variables; return { impact: message(`${descriptor}.impact`, variables), code: error.code, @@ -68788,6 +68835,9 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'workspace-changes-rejected', 'closed-issue-reconciliation-failed', 'approval-evidence-read-failed', + 'stored-configuration-schema-missing', + 'stored-configuration-schema-unsupported', + 'stored-configuration-invalid', ]); const PRESERVED_STATE = 'Existing persisted state and completed external effects were preserved.'; const UNCHANGED_STATE = 'No new state or external effect was created.'; @@ -68979,6 +69029,9 @@ class ApplicationError extends Error { exports.ApplicationError = ApplicationError; _ApplicationError_cause = new WeakMap(); const RECOVERY_VARIABLE_KEYS = Object.freeze({ + 'stored-configuration-schema-missing': Object.freeze(['issueNumber', 'expectedVersion']), + 'stored-configuration-schema-unsupported': Object.freeze(['issueNumber', 'expectedVersion', 'actualVersion']), + 'stored-configuration-invalid': Object.freeze(['issueNumber', 'expectedVersion']), 'closed-issue-reconciliation-failed': Object.freeze(['resultId', 'phase', 'state', 'reason', 'changed', 'remaining']), 'approval-evidence-read-failed': Object.freeze(['operation', 'reason', 'permission']), 'pull-request-link-restored': Object.freeze([]), @@ -69008,6 +69061,13 @@ function normalizeApplicationErrorRecovery(recovery) { || actualKeys.some((key, index) => key !== expectedKeys[index])) { throw new TypeError(`Application error recovery variables are invalid for ${recovery.id}.`); } + if (recovery.id.startsWith('stored-configuration-') + && (![variables.issueNumber, variables.expectedVersion].every(value => typeof value === 'number' && Number.isSafeInteger(value) && value > 0) + || (recovery.id === 'stored-configuration-schema-unsupported' + && !(typeof variables.actualVersion === 'number' + && Number.isSafeInteger(variables.actualVersion) && variables.actualVersion >= 0)))) { + throw new TypeError('Stored configuration diagnostic is invalid.'); + } if (recovery.id === 'managed-branch-enrichment-failed' && (typeof variables.branchName !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/u.test(variables.branchName))) { @@ -74451,6 +74511,7 @@ function combineOrganizationAccess(secrets, variables) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.isGithubAlreadyExists = exports.isGithubPermissionDenied = exports.isGithubRateLimited = exports.isGithubNotFound = exports.getGithubErrorStatus = void 0; +exports.githubReadErrorCode = githubReadErrorCode; const getGithubErrorStatus = (error) => { if (typeof error !== "object" || error === null) return undefined; @@ -74460,6 +74521,23 @@ const getGithubErrorStatus = (error) => { exports.getGithubErrorStatus = getGithubErrorStatus; const isGithubNotFound = (error) => (0, exports.getGithubErrorStatus)(error) === 404; exports.isGithubNotFound = isGithubNotFound; +/** Maps failed metadata reads without copying untrusted provider text. */ +function githubReadErrorCode(error) { + const status = (0, exports.getGithubErrorStatus)(error); + if (status === 401) + return 'authorization.credential-invalid'; + if ((0, exports.isGithubRateLimited)(error)) + return 'provider.rate-limited'; + if (status === 403) + return 'authorization.denied'; + if (status === 404) + return 'provider.not-found'; + if (status === 409 || status === 412) + return 'provider.conflict'; + if (status === 400 || status === 422) + return 'provider.contract-invalid'; + return 'provider.unavailable'; +} const isGithubRateLimited = (error) => { const status = (0, exports.getGithubErrorStatus)(error); if (status === 429) @@ -75170,8 +75248,9 @@ class IssueContentRepository { return issue.body ?? ''; } catch (error) { - (0, logger_1.logError)((0, application_error_1.toApplicationError)(error, 'provider.unavailable', `Unable to read issue #${issueNumber} description.`)); - throw error; + const semanticError = (0, application_error_1.toApplicationError)(error, (0, github_error_policy_1.githubReadErrorCode)(error), 'Unable to read the issue or pull-request description.'); + (0, logger_1.logError)(semanticError); + throw semanticError; } }; this.getIssueDescription = async (owner, repository, issueNumber, token) => { @@ -88967,18 +89046,44 @@ class ConfigurationHandler extends issue_content_interface_1.IssueContentInterfa return this.internalUpdate(query, (0, configuration_payload_policy_1.buildConfigurationPayload)(context, storedRaw)); }; this.get = async (query) => { + let description; + try { + description = await this.issueDescriptionPort.getDescription(query.owner, query.repository, query.issueNumber, query.token); + } + catch (cause) { + const semanticError = (0, application_error_1.toApplicationError)(cause, 'provider.unavailable', 'Unable to read issue configuration.'); + (0, logger_1.logError)(semanticError); + throw semanticError; + } + const variables = { issueNumber: query.issueNumber, expectedVersion: config_1.CONFIG_SCHEMA_VERSION }; + let recovery = { id: 'stored-configuration-invalid', variables }; try { - const description = await this.issueDescriptionPort.getDescription(query.owner, query.repository, query.issueNumber, query.token); const config = this.getContent(description); if (config === undefined) { + if (description?.includes('')) { + throw new Error('Stored configuration markers are inconsistent.'); + } return undefined; } - const branchConfig = (0, config_1.requireCurrentConfigurationPayload)(JSON.parse(config)); + const parsed = JSON.parse(config); + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { + const version = parsed.schemaVersion; + if (version === undefined) { + recovery = { id: 'stored-configuration-schema-missing', variables }; + } + else if (typeof version === 'number' && Number.isSafeInteger(version) + && version >= 0 && version !== config_1.CONFIG_SCHEMA_VERSION) { + recovery = { id: 'stored-configuration-schema-unsupported', variables: { ...variables, actualVersion: version } }; + } + } + const branchConfig = (0, config_1.requireCurrentConfigurationPayload)(parsed); return new config_1.Config(branchConfig); } - catch (error) { - (0, logger_1.logError)((0, application_error_1.toApplicationError)(error, 'configuration.invalid', 'Unable to read issue configuration.')); - throw error; + catch (cause) { + const semanticError = (0, application_error_1.toApplicationError)(cause, 'configuration.invalid', 'Stored issue/PR configuration is invalid.', { recovery }); + (0, logger_1.logError)(semanticError); + throw semanticError; } }; } diff --git a/docs/configuration.mdx b/docs/configuration.mdx index 555c142eb..a987ad8aa 100644 --- a/docs/configuration.mdx +++ b/docs/configuration.mdx @@ -326,6 +326,14 @@ When updating the configuration (for example, after a push or label change), Cop The hidden block is owned by Copilot. Do not add third-party or manually managed keys to it; they are outside the contract and will be removed on the next write. +An invalid stored block fails with `configuration.invalid` and is not retryable +without correction. Its diagnostic identifies the issue/PR, distinguishes a +missing schema from an unsupported version or malformed state, and states the +required version. An absent block remains distinct from invalid stored state. +Follow [stored-configuration recovery](/security-operations/operations/troubleshooting#unsupported-stored-configuration) +before editing or removing a block; never add a version number merely to bypass +validation. No automatic migration or reset is performed. + ### Persistence failures If GitHub cannot read or update the issue description, the run fails closed after diff --git a/docs/security-operations/operations/error-reference.mdx b/docs/security-operations/operations/error-reference.mdx index 110dd972f..fb122c06a 100644 --- a/docs/security-operations/operations/error-reference.mdx +++ b/docs/security-operations/operations/error-reference.mdx @@ -41,6 +41,16 @@ Retryability is determined by the code. A specific operation can make a normally retryable failure terminal, but presentation never turns a terminal code into a retry recommendation. +For stored issue/PR state, `configuration.invalid` includes the affected number, +the expected schema version, and a missing-schema, unsupported-version or +malformed-content diagnosis in the failure annotation and Job Summary. It +remains nonretryable until corrected; the same correlation reference survives +to the final Action failure. The read does not rewrite the body. Use +[stored-configuration recovery](/security-operations/operations/troubleshooting#unsupported-stored-configuration): +only a verified obsolete unmanaged block may be removed after preserving a +copy. Potentially valuable managed state requires maintainer review and verified +restoration. Provider/authentication failures are not schema failures. + Some partial operations add a closed recovery variant, for example to name a retained managed branch or a closed issue whose final explanation was not published. These variants use reviewed catalog templates and only bounded, diff --git a/docs/security-operations/operations/troubleshooting.mdx b/docs/security-operations/operations/troubleshooting.mdx index 737179f10..d8f38bd1a 100644 --- a/docs/security-operations/operations/troubleshooting.mdx +++ b/docs/security-operations/operations/troubleshooting.mdx @@ -548,3 +548,38 @@ When current-repository organization reuse reports configuration/access changed after review, inspect the credential-health workflow too. Its installation or removal changes required bootstrap permissions. Prepare and approve a fresh plan instead of applying a previously audited permission set. + +## Unsupported stored configuration + +An issue or PR with an invalid stored Copilot block stops with +`configuration.invalid` and **Retryable: No**. The annotation and Job Summary +identify its number, the missing/unsupported/malformed state, the required +schema version (currently `3`), and the same reference. Retrying unchanged state +will fail again. The failed read does not change the body or stored state. + +| Stored content | Meaning | Next action | +|---|---|---| +| No Copilot block | No previous state to restore | An unlinked PR can follow its normal PR workflow. | +| Missing schema, unsupported version, malformed JSON/value or incomplete delimiters | Stored state cannot be trusted | A maintainer must review it before retrying. | +| Valid current-schema block | Supported state | Continue through normal validation; this is not a reset instruction. | + +Recovery is manual and depends on what the block owns: + +1. Preserve the original body using GitHub history or a copy before editing. +2. If a maintainer verifies that the issue/PR is unmanaged and the block is + only an obsolete marker with no branch, release, hotfix or orchestration + state to retain, remove **only that block**, preserve the human description, + and rerun the failed workflow. An obsolete reproduction example can instead + show its JSON without live Copilot comment delimiters: the current reader + also recognizes those delimiters inside fenced issue/PR examples. +3. If managed state may exist or ownership is unclear, stop. Ask a maintainer + to inspect linked branches and operations and restore verified supported + state through the supported workflow before rerunning. Do not discard + unknown state. + +Never repair this by merely adding `schemaVersion: 3`, changing a version number, +automatically migrating/resetting state, or turning the failed check into a +successful skip. A version field alone does not prove that the stored branch or +operation facts are valid. Authorization and temporary GitHub read failures +retain their own error categories; follow their permission/provider recovery +instead of deleting configuration. diff --git a/specs/CATALOG.md b/specs/CATALOG.md index e78248ac6..7729c582d 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -14,7 +14,7 @@ debt or convert unknown historic intent into a design decision. | `release-orchestration` | Implemented | Release and hotfix promotion, publication, bounded merge readiness recovery, and receipt-verified reconciliation without republication | [Configurable production-first release orchestration](./configurable-release-orchestration.md) + 2 companion | 59 paths · 2026-10-16 | | `merge-queue-readiness` | Implemented | Fail-closed validation of required checks and merge-group workflow support; optional manual-only RepoWise analysis with independent CI and verified required-check migration; cached RepoWise exports and bounded full-history secret processing with truthful completeness and resource evidence | [Merge queue readiness and effective target rules](./merge-queue-readiness.md) | 29 paths · 2026-10-21 | | `bugbot-review-state-reconciliation` | Implemented | Reconcile review snapshots, findings, threads, comments, and check conclusions | [Bugbot review-state reconciliation](./bugbot-review-state-reconciliation.md) | 59 paths · 2026-10-11 | -| `execution-lifecycle` | Implemented | Shared GitHub Action lifecycle from event admission through durable user-facing results | [Execution admission, queueing, routing, and result publication](./execution-admission-queue-and-publication.md) + 3 companion | 97 paths · 2026-10-14 | +| `execution-lifecycle` | Implemented | Shared GitHub Action lifecycle from event admission through durable user-facing results | [Execution admission, queueing, routing, and result publication](./execution-admission-queue-and-publication.md) + 3 companion | 104 paths · 2026-10-21 | | `architecture-quality-hardening` | Implemented | Close verified concurrency, error-contract, context-coupling, fan-out, setup/doctor, and provider-policy risks in dependency order | [Architecture quality and scalability hardening](./architecture-quality-and-scalability-hardening.md) + 1 companion | 72 paths · 2026-10-13 | | `setup-and-doctor` | Implemented | Plan, validate, provision, and audit a repository installation without exposing credentials | [Setup, configuration, credentials, and doctor](./setup-configuration-credentials-and-doctor.md) + 5 companion | 326 paths · 2026-10-20 | | `local-web-setup-assistant` | Proposed | Offer a packaged, loopback-only Svelte setup interface over the existing engine with four-language guidance, English CLI help, basic/custom first-run paths, editable question and plan review, source-labelled GitHub facts, assisted CI and Project evidence, role-separated PATs, and truthful itemized outcomes; inspect existing configuration and apply independent, scope-aware runtime adjustments with permission, drift and read-back verification | [Local web setup assistant](./local-web-setup-assistant.md) + 1 companion | 238 paths · 2026-10-21 | @@ -81,13 +81,13 @@ debt or convert unknown historic intent into a design decision. ### `execution-lifecycle` — Execution admission, queueing, routing, and result publication - Owner: Copilot maintainers -- Last verified: 2026-10-14 +- Last verified: 2026-10-21 - Specifications: [`specs/execution-admission-queue-and-publication.md`](./execution-admission-queue-and-publication.md) · [`specs/execution-error-and-context-hardening.md`](./execution-error-and-context-hardening.md) · [`specs/execution-boundary-closure-audit.md`](./execution-boundary-closure-audit.md) · [`specs/push-and-single-action-context-hardening.md`](./push-and-single-action-context-hardening.md) - Workflows: [`.github/workflows/copilot_issue.yml`](../.github/workflows/copilot_issue.yml) · [`.github/workflows/copilot_issue_comment.yml`](../.github/workflows/copilot_issue_comment.yml) · [`.github/workflows/copilot_pull_request.yml`](../.github/workflows/copilot_pull_request.yml) · [`.github/workflows/copilot_pull_request_comment.yml`](../.github/workflows/copilot_pull_request_comment.yml) · [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) - Entrypoints: [`src/actions/github_action.ts`](../src/actions/github_action.ts) · [`src/actions/common_action.ts`](../src/actions/common_action.ts) -- Core code: [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`scripts/validate-coverage-budgets.cjs`](../scripts/validate-coverage-budgets.cjs) · [`src/actions/main_run_lifecycle.ts`](../src/actions/main_run_lifecycle.ts) · [`src/actions/main_run_route.ts`](../src/actions/main_run_route.ts) · [`src/actions/github_action_completion.ts`](../src/actions/github_action_completion.ts) · [`src/api.ts`](../src/api.ts) · [`src/data/model/application_error.ts`](../src/data/model/application_error.ts) · [`src/application/errors/application_error.ts`](../src/application/errors/application_error.ts) · [`src/application/errors/application_error_context.ts`](../src/application/errors/application_error_context.ts) · [`src/application/policies/application_error_message_catalog.ts`](../src/application/policies/application_error_message_catalog.ts) · [`src/application/policies/application_error_presentation_policy.ts`](../src/application/policies/application_error_presentation_policy.ts) · [`src/application/policies/bugbot_result_finding_state_projection_policy.ts`](../src/application/policies/bugbot_result_finding_state_projection_policy.ts) · [`src/application/policies/bugbot_telemetry_projection_policy.ts`](../src/application/policies/bugbot_telemetry_projection_policy.ts) · [`src/application/policies/action_summary_policy.ts`](../src/application/policies/action_summary_policy.ts) · [`src/application/policies/copilot_evidence_policy.ts`](../src/application/policies/copilot_evidence_policy.ts) · [`src/application/policies/lifecycle_state_policy.ts`](../src/application/policies/lifecycle_state_policy.ts) · [`src/application/policies/status_command_policy.ts`](../src/application/policies/status_command_policy.ts) · [`src/application/policies/github_execution_admission_policy.ts`](../src/application/policies/github_execution_admission_policy.ts) · [`src/application/policies/workflow_queue_policy.ts`](../src/application/policies/workflow_queue_policy.ts) · [`src/application/usecases/actions/lifecycle_synchronization_context.ts`](../src/application/usecases/actions/lifecycle_synchronization_context.ts) · [`src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts`](../src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts) · [`src/application/usecases/steps/commit/bugbot/bugbot_review_operation_context.ts`](../src/application/usecases/steps/commit/bugbot/bugbot_review_operation_context.ts) · [`src/application/policies/semantic_result_publication_policy.ts`](../src/application/policies/semantic_result_publication_policy.ts) · [`src/application/usecases/steps/common/status_card_publication_workflow.ts`](../src/application/usecases/steps/common/status_card_publication_workflow.ts) · [`src/application/usecases/comment_automation_context.ts`](../src/application/usecases/comment_automation_context.ts) · [`src/application/usecases/issue_workflow_context.ts`](../src/application/usecases/issue_workflow_context.ts) · [`src/application/usecases/pull_request_workflow_context.ts`](../src/application/usecases/pull_request_workflow_context.ts) · [`src/application/usecases/push_single_action_contexts.ts`](../src/application/usecases/push_single_action_contexts.ts) · [`src/application/usecases/steps/common/check_permissions_workflow.ts`](../src/application/usecases/steps/common/check_permissions_workflow.ts) · [`src/application/usecases/steps/common/comment_language_translation_workflow.ts`](../src/application/usecases/steps/common/comment_language_translation_workflow.ts) · [`src/application/usecases/steps/common/think_workflow.ts`](../src/application/usecases/steps/common/think_workflow.ts) · [`src/application/usecases/steps/common/update_title_workflow.ts`](../src/application/usecases/steps/common/update_title_workflow.ts) · [`src/application/usecases/steps/common/project_content_link_workflow.ts`](../src/application/usecases/steps/common/project_content_link_workflow.ts) · [`src/application/usecases/steps/common/publish_resume_workflow.ts`](../src/application/usecases/steps/common/publish_resume_workflow.ts) · [`src/application/usecases/steps/common/store_configuration_use_case.ts`](../src/application/usecases/steps/common/store_configuration_use_case.ts) · [`src/infrastructure/composition/shared_capability_port_binding.ts`](../src/infrastructure/composition/shared_capability_port_binding.ts) · [`src/infrastructure/composition/lifecycle_capability_port_binding.ts`](../src/infrastructure/composition/lifecycle_capability_port_binding.ts) · [`src/infrastructure/composition/lifecycle_state_composition_root.ts`](../src/infrastructure/composition/lifecycle_state_composition_root.ts) · [`src/infrastructure/composition/push_single_action_capability_port_binding.ts`](../src/infrastructure/composition/push_single_action_capability_port_binding.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`src/application/policies/configuration_persistence_policy.ts`](../src/application/policies/configuration_persistence_policy.ts) · [`src/application/ports/project_board_query_ports.ts`](../src/application/ports/project_board_query_ports.ts) · [`src/application/usecases/steps/commit/check_changes_issue_size_use_case.ts`](../src/application/usecases/steps/commit/check_changes_issue_size_use_case.ts) · [`src/application/usecases/steps/commit/check_changes_issue_size_workflow.ts`](../src/application/usecases/steps/commit/check_changes_issue_size_workflow.ts) · [`src/application/usecases/steps/commit/update_change_size_labels.ts`](../src/application/usecases/steps/commit/update_change_size_labels.ts) · [`src/data/repository/project/project_board_item_query.ts`](../src/data/repository/project/project_board_item_query.ts) · [`src/infrastructure/composition/change_size_project_membership_composition_root.ts`](../src/infrastructure/composition/change_size_project_membership_composition_root.ts) -- Tests: [`src/tooling/__tests__/coverage_budget_validator.test.ts`](../src/tooling/__tests__/coverage_budget_validator.test.ts) · [`src/__tests__/api.test.ts`](../src/__tests__/api.test.ts) · [`src/actions/__tests__/github_action.test.ts`](../src/actions/__tests__/github_action.test.ts) · [`src/actions/__tests__/github_action_completion.test.ts`](../src/actions/__tests__/github_action_completion.test.ts) · [`src/application/errors/__tests__/application_error.test.ts`](../src/application/errors/__tests__/application_error.test.ts) · [`src/application/policies/__tests__/application_error_message_catalog.test.ts`](../src/application/policies/__tests__/application_error_message_catalog.test.ts) · [`src/application/policies/__tests__/application_error_presentation_policy.test.ts`](../src/application/policies/__tests__/application_error_presentation_policy.test.ts) · [`src/application/policies/__tests__/bugbot_result_finding_state_projection_policy.test.ts`](../src/application/policies/__tests__/bugbot_result_finding_state_projection_policy.test.ts) · [`src/application/policies/__tests__/bugbot_telemetry_projection_policy.test.ts`](../src/application/policies/__tests__/bugbot_telemetry_projection_policy.test.ts) · [`src/application/policies/__tests__/action_summary_policy.test.ts`](../src/application/policies/__tests__/action_summary_policy.test.ts) · [`src/application/policies/__tests__/copilot_evidence_policy.test.ts`](../src/application/policies/__tests__/copilot_evidence_policy.test.ts) · [`src/application/policies/__tests__/lifecycle_state_policy.test.ts`](../src/application/policies/__tests__/lifecycle_state_policy.test.ts) · [`src/application/policies/__tests__/status_command_policy.test.ts`](../src/application/policies/__tests__/status_command_policy.test.ts) · [`src/application/usecases/actions/__tests__/lifecycle_synchronization_context.test.ts`](../src/application/usecases/actions/__tests__/lifecycle_synchronization_context.test.ts) · [`src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts`](../src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts) · [`src/application/usecases/actions/__tests__/lifecycle_event_replay.integration.test.ts`](../src/application/usecases/actions/__tests__/lifecycle_event_replay.integration.test.ts) · [`src/architecture/__tests__/execution_import_ratchet.test.ts`](../src/architecture/__tests__/execution_import_ratchet.test.ts) · [`src/architecture/__tests__/raw_error_logging_boundaries.test.ts`](../src/architecture/__tests__/raw_error_logging_boundaries.test.ts) · [`src/application/usecases/steps/commit/bugbot/__tests__/bugbot_review_operation_context.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/bugbot_review_operation_context.test.ts) · [`src/application/usecases/steps/common/__tests__/shared_capability_context_projection.test.ts`](../src/application/usecases/steps/common/__tests__/shared_capability_context_projection.test.ts) · [`src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts`](../src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts) · [`src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts) · [`src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts`](../src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts) · [`src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts) · [`src/infrastructure/composition/__tests__/lifecycle_state_composition_root.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_state_composition_root.test.ts) · [`src/application/usecases/__tests__/push_single_action_contexts.test.ts`](../src/application/usecases/__tests__/push_single_action_contexts.test.ts) · [`src/application/usecases/__tests__/single_action_workflow.test.ts`](../src/application/usecases/__tests__/single_action_workflow.test.ts) · [`src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts) · [`src/application/usecases/execution/__tests__/resolve_github_execution_admission_use_case.test.ts`](../src/application/usecases/execution/__tests__/resolve_github_execution_admission_use_case.test.ts) · [`src/application/usecases/workflow/__tests__/wait_for_previous_workflow_runs_use_case.test.ts`](../src/application/usecases/workflow/__tests__/wait_for_previous_workflow_runs_use_case.test.ts) · [`src/application/policies/__tests__/configuration_persistence_policy.test.ts`](../src/application/policies/__tests__/configuration_persistence_policy.test.ts) · [`src/application/usecases/steps/commit/__tests__/check_changes_issue_size_use_case.test.ts`](../src/application/usecases/steps/commit/__tests__/check_changes_issue_size_use_case.test.ts) · [`src/application/usecases/steps/commit/__tests__/update_change_size_labels.test.ts`](../src/application/usecases/steps/commit/__tests__/update_change_size_labels.test.ts) · [`src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts`](../src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts) · [`src/infrastructure/composition/__tests__/change_size_project_membership_composition_root.test.ts`](../src/infrastructure/composition/__tests__/change_size_project_membership_composition_root.test.ts) -- User documentation: [`docs/overview.mdx`](../docs/overview.mdx) · [`docs/bugbot/programmatic-api.mdx`](../docs/bugbot/programmatic-api.mdx) · [`docs/security-operations/operations/error-reference.mdx`](../docs/security-operations/operations/error-reference.mdx) · [`docs/security-operations/operations/troubleshooting.mdx`](../docs/security-operations/operations/troubleshooting.mdx) · [`docs/bugbot/quality-observability.mdx`](../docs/bugbot/quality-observability.mdx) · [`docs/bugbot/failure-scenarios.mdx`](../docs/bugbot/failure-scenarios.mdx) · [`docs/development/architecture.mdx`](../docs/development/architecture.mdx) · [`docs/issues/assignees-and-projects.mdx`](../docs/issues/assignees-and-projects.mdx) +- Core code: [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`scripts/validate-coverage-budgets.cjs`](../scripts/validate-coverage-budgets.cjs) · [`src/actions/main_run_lifecycle.ts`](../src/actions/main_run_lifecycle.ts) · [`src/actions/main_run_route.ts`](../src/actions/main_run_route.ts) · [`src/actions/github_action_completion.ts`](../src/actions/github_action_completion.ts) · [`src/api.ts`](../src/api.ts) · [`src/data/model/application_error.ts`](../src/data/model/application_error.ts) · [`src/application/errors/application_error.ts`](../src/application/errors/application_error.ts) · [`src/application/errors/application_error_context.ts`](../src/application/errors/application_error_context.ts) · [`src/application/policies/application_error_message_catalog.ts`](../src/application/policies/application_error_message_catalog.ts) · [`src/application/policies/application_error_presentation_policy.ts`](../src/application/policies/application_error_presentation_policy.ts) · [`src/application/policies/bugbot_result_finding_state_projection_policy.ts`](../src/application/policies/bugbot_result_finding_state_projection_policy.ts) · [`src/application/policies/bugbot_telemetry_projection_policy.ts`](../src/application/policies/bugbot_telemetry_projection_policy.ts) · [`src/application/policies/action_summary_policy.ts`](../src/application/policies/action_summary_policy.ts) · [`src/application/policies/copilot_evidence_policy.ts`](../src/application/policies/copilot_evidence_policy.ts) · [`src/application/policies/lifecycle_state_policy.ts`](../src/application/policies/lifecycle_state_policy.ts) · [`src/application/policies/status_command_policy.ts`](../src/application/policies/status_command_policy.ts) · [`src/application/policies/github_execution_admission_policy.ts`](../src/application/policies/github_execution_admission_policy.ts) · [`src/application/policies/workflow_queue_policy.ts`](../src/application/policies/workflow_queue_policy.ts) · [`src/application/usecases/actions/lifecycle_synchronization_context.ts`](../src/application/usecases/actions/lifecycle_synchronization_context.ts) · [`src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts`](../src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts) · [`src/application/usecases/steps/commit/bugbot/bugbot_review_operation_context.ts`](../src/application/usecases/steps/commit/bugbot/bugbot_review_operation_context.ts) · [`src/application/policies/semantic_result_publication_policy.ts`](../src/application/policies/semantic_result_publication_policy.ts) · [`src/application/usecases/steps/common/status_card_publication_workflow.ts`](../src/application/usecases/steps/common/status_card_publication_workflow.ts) · [`src/application/usecases/comment_automation_context.ts`](../src/application/usecases/comment_automation_context.ts) · [`src/application/usecases/issue_workflow_context.ts`](../src/application/usecases/issue_workflow_context.ts) · [`src/application/usecases/pull_request_workflow_context.ts`](../src/application/usecases/pull_request_workflow_context.ts) · [`src/application/usecases/push_single_action_contexts.ts`](../src/application/usecases/push_single_action_contexts.ts) · [`src/application/usecases/steps/common/check_permissions_workflow.ts`](../src/application/usecases/steps/common/check_permissions_workflow.ts) · [`src/application/usecases/steps/common/comment_language_translation_workflow.ts`](../src/application/usecases/steps/common/comment_language_translation_workflow.ts) · [`src/application/usecases/steps/common/think_workflow.ts`](../src/application/usecases/steps/common/think_workflow.ts) · [`src/application/usecases/steps/common/update_title_workflow.ts`](../src/application/usecases/steps/common/update_title_workflow.ts) · [`src/application/usecases/steps/common/project_content_link_workflow.ts`](../src/application/usecases/steps/common/project_content_link_workflow.ts) · [`src/application/usecases/steps/common/publish_resume_workflow.ts`](../src/application/usecases/steps/common/publish_resume_workflow.ts) · [`src/application/usecases/steps/common/store_configuration_use_case.ts`](../src/application/usecases/steps/common/store_configuration_use_case.ts) · [`src/infrastructure/composition/shared_capability_port_binding.ts`](../src/infrastructure/composition/shared_capability_port_binding.ts) · [`src/infrastructure/composition/lifecycle_capability_port_binding.ts`](../src/infrastructure/composition/lifecycle_capability_port_binding.ts) · [`src/infrastructure/composition/lifecycle_state_composition_root.ts`](../src/infrastructure/composition/lifecycle_state_composition_root.ts) · [`src/infrastructure/composition/push_single_action_capability_port_binding.ts`](../src/infrastructure/composition/push_single_action_capability_port_binding.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`src/application/policies/configuration_persistence_policy.ts`](../src/application/policies/configuration_persistence_policy.ts) · [`src/application/ports/project_board_query_ports.ts`](../src/application/ports/project_board_query_ports.ts) · [`src/application/usecases/steps/commit/check_changes_issue_size_use_case.ts`](../src/application/usecases/steps/commit/check_changes_issue_size_use_case.ts) · [`src/application/usecases/steps/commit/check_changes_issue_size_workflow.ts`](../src/application/usecases/steps/commit/check_changes_issue_size_workflow.ts) · [`src/application/usecases/steps/commit/update_change_size_labels.ts`](../src/application/usecases/steps/commit/update_change_size_labels.ts) · [`src/data/repository/project/project_board_item_query.ts`](../src/data/repository/project/project_board_item_query.ts) · [`src/infrastructure/composition/change_size_project_membership_composition_root.ts`](../src/infrastructure/composition/change_size_project_membership_composition_root.ts) · [`src/manager/description/configuration_handler.ts`](../src/manager/description/configuration_handler.ts) · [`src/data/repository/issue/issue_content_repository.ts`](../src/data/repository/issue/issue_content_repository.ts) · [`src/data/repository/github/github_error_policy.ts`](../src/data/repository/github/github_error_policy.ts) +- Tests: [`src/tooling/__tests__/coverage_budget_validator.test.ts`](../src/tooling/__tests__/coverage_budget_validator.test.ts) · [`src/__tests__/api.test.ts`](../src/__tests__/api.test.ts) · [`src/actions/__tests__/github_action.test.ts`](../src/actions/__tests__/github_action.test.ts) · [`src/actions/__tests__/github_action_completion.test.ts`](../src/actions/__tests__/github_action_completion.test.ts) · [`src/application/errors/__tests__/application_error.test.ts`](../src/application/errors/__tests__/application_error.test.ts) · [`src/application/policies/__tests__/application_error_message_catalog.test.ts`](../src/application/policies/__tests__/application_error_message_catalog.test.ts) · [`src/application/policies/__tests__/application_error_presentation_policy.test.ts`](../src/application/policies/__tests__/application_error_presentation_policy.test.ts) · [`src/application/policies/__tests__/bugbot_result_finding_state_projection_policy.test.ts`](../src/application/policies/__tests__/bugbot_result_finding_state_projection_policy.test.ts) · [`src/application/policies/__tests__/bugbot_telemetry_projection_policy.test.ts`](../src/application/policies/__tests__/bugbot_telemetry_projection_policy.test.ts) · [`src/application/policies/__tests__/action_summary_policy.test.ts`](../src/application/policies/__tests__/action_summary_policy.test.ts) · [`src/application/policies/__tests__/copilot_evidence_policy.test.ts`](../src/application/policies/__tests__/copilot_evidence_policy.test.ts) · [`src/application/policies/__tests__/lifecycle_state_policy.test.ts`](../src/application/policies/__tests__/lifecycle_state_policy.test.ts) · [`src/application/policies/__tests__/status_command_policy.test.ts`](../src/application/policies/__tests__/status_command_policy.test.ts) · [`src/application/usecases/actions/__tests__/lifecycle_synchronization_context.test.ts`](../src/application/usecases/actions/__tests__/lifecycle_synchronization_context.test.ts) · [`src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts`](../src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts) · [`src/application/usecases/actions/__tests__/lifecycle_event_replay.integration.test.ts`](../src/application/usecases/actions/__tests__/lifecycle_event_replay.integration.test.ts) · [`src/architecture/__tests__/execution_import_ratchet.test.ts`](../src/architecture/__tests__/execution_import_ratchet.test.ts) · [`src/architecture/__tests__/raw_error_logging_boundaries.test.ts`](../src/architecture/__tests__/raw_error_logging_boundaries.test.ts) · [`src/application/usecases/steps/commit/bugbot/__tests__/bugbot_review_operation_context.test.ts`](../src/application/usecases/steps/commit/bugbot/__tests__/bugbot_review_operation_context.test.ts) · [`src/application/usecases/steps/common/__tests__/shared_capability_context_projection.test.ts`](../src/application/usecases/steps/common/__tests__/shared_capability_context_projection.test.ts) · [`src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts`](../src/application/usecases/steps/common/__tests__/publish_resume_use_case.test.ts) · [`src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/shared_capability_port_binding.test.ts) · [`src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts`](../src/application/usecases/__tests__/issue_pull_request_context_projection.test.ts) · [`src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_capability_port_binding.test.ts) · [`src/infrastructure/composition/__tests__/lifecycle_state_composition_root.test.ts`](../src/infrastructure/composition/__tests__/lifecycle_state_composition_root.test.ts) · [`src/application/usecases/__tests__/push_single_action_contexts.test.ts`](../src/application/usecases/__tests__/push_single_action_contexts.test.ts) · [`src/application/usecases/__tests__/single_action_workflow.test.ts`](../src/application/usecases/__tests__/single_action_workflow.test.ts) · [`src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts`](../src/infrastructure/composition/__tests__/push_single_action_capability_port_binding.test.ts) · [`src/application/usecases/execution/__tests__/resolve_github_execution_admission_use_case.test.ts`](../src/application/usecases/execution/__tests__/resolve_github_execution_admission_use_case.test.ts) · [`src/application/usecases/workflow/__tests__/wait_for_previous_workflow_runs_use_case.test.ts`](../src/application/usecases/workflow/__tests__/wait_for_previous_workflow_runs_use_case.test.ts) · [`src/application/policies/__tests__/configuration_persistence_policy.test.ts`](../src/application/policies/__tests__/configuration_persistence_policy.test.ts) · [`src/application/usecases/steps/commit/__tests__/check_changes_issue_size_use_case.test.ts`](../src/application/usecases/steps/commit/__tests__/check_changes_issue_size_use_case.test.ts) · [`src/application/usecases/steps/commit/__tests__/update_change_size_labels.test.ts`](../src/application/usecases/steps/commit/__tests__/update_change_size_labels.test.ts) · [`src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts`](../src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts) · [`src/infrastructure/composition/__tests__/change_size_project_membership_composition_root.test.ts`](../src/infrastructure/composition/__tests__/change_size_project_membership_composition_root.test.ts) · [`src/manager/description/__tests__/configuration_recovery.test.ts`](../src/manager/description/__tests__/configuration_recovery.test.ts) · [`src/manager/description/__tests__/configuration_handler.test.ts`](../src/manager/description/__tests__/configuration_handler.test.ts) · [`src/data/repository/issue/__tests__/issue_content_repository.test.ts`](../src/data/repository/issue/__tests__/issue_content_repository.test.ts) +- User documentation: [`docs/overview.mdx`](../docs/overview.mdx) · [`docs/bugbot/programmatic-api.mdx`](../docs/bugbot/programmatic-api.mdx) · [`docs/security-operations/operations/error-reference.mdx`](../docs/security-operations/operations/error-reference.mdx) · [`docs/security-operations/operations/troubleshooting.mdx`](../docs/security-operations/operations/troubleshooting.mdx) · [`docs/bugbot/quality-observability.mdx`](../docs/bugbot/quality-observability.mdx) · [`docs/bugbot/failure-scenarios.mdx`](../docs/bugbot/failure-scenarios.mdx) · [`docs/development/architecture.mdx`](../docs/development/architecture.mdx) · [`docs/issues/assignees-and-projects.mdx`](../docs/issues/assignees-and-projects.mdx) · [`docs/configuration.mdx`](../docs/configuration.mdx) ### `architecture-quality-hardening` — Architecture quality and scalability hardening diff --git a/specs/catalog.json b/specs/catalog.json index 7bb958ab6..d2a52a9bb 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -520,7 +520,7 @@ "status": "implemented", "scope": "Shared GitHub Action lifecycle from event admission through durable user-facing results", "owner": "Copilot maintainers", - "lastVerified": "2026-10-14", + "lastVerified": "2026-10-21", "specs": [ "specs/execution-admission-queue-and-publication.md", "specs/execution-error-and-context-hardening.md", @@ -585,7 +585,10 @@ "src/application/usecases/steps/commit/check_changes_issue_size_workflow.ts", "src/application/usecases/steps/commit/update_change_size_labels.ts", "src/data/repository/project/project_board_item_query.ts", - "src/infrastructure/composition/change_size_project_membership_composition_root.ts" + "src/infrastructure/composition/change_size_project_membership_composition_root.ts", + "src/manager/description/configuration_handler.ts", + "src/data/repository/issue/issue_content_repository.ts", + "src/data/repository/github/github_error_policy.ts" ], "tests": [ "src/tooling/__tests__/coverage_budget_validator.test.ts", @@ -622,7 +625,10 @@ "src/application/usecases/steps/commit/__tests__/check_changes_issue_size_use_case.test.ts", "src/application/usecases/steps/commit/__tests__/update_change_size_labels.test.ts", "src/data/repository/project/__tests__/project_board_visibility_recovery.test.ts", - "src/infrastructure/composition/__tests__/change_size_project_membership_composition_root.test.ts" + "src/infrastructure/composition/__tests__/change_size_project_membership_composition_root.test.ts", + "src/manager/description/__tests__/configuration_recovery.test.ts", + "src/manager/description/__tests__/configuration_handler.test.ts", + "src/data/repository/issue/__tests__/issue_content_repository.test.ts" ], "documentation": [ "docs/overview.mdx", @@ -632,7 +638,8 @@ "docs/bugbot/quality-observability.mdx", "docs/bugbot/failure-scenarios.mdx", "docs/development/architecture.mdx", - "docs/issues/assignees-and-projects.mdx" + "docs/issues/assignees-and-projects.mdx", + "docs/configuration.mdx" ] }, { diff --git a/specs/execution-error-and-context-hardening.md b/specs/execution-error-and-context-hardening.md index 4c5bc2675..f245e17b0 100644 --- a/specs/execution-error-and-context-hardening.md +++ b/specs/execution-error-and-context-hardening.md @@ -804,3 +804,105 @@ pure cases cover native closure, closed label callbacks, open state and missing state. They belong only to this owner: the #474 extension is now 15 execution cases, separate from 56 semantic publication and six autofix cases. Terminal routing/bot-event tests belong to branch synchronization and are not recounted. + +### Stored configuration diagnostics and recovery: #635 (2026-10-21) + +**Problem and preserved contract.** Run +[39565969565](https://github.com/vypdev/zstandard/actions/runs/39565969565/job/119108205733) +rejected a PR block without a schema version. Rejection is intentional: +only the current durable schema is supported. The defect was that the reader +logged a semantic configuration error but rethrew its raw cause; the final +boundary consequently presented `workflow.failed` and retryable true. An +unlinked PR without any block remains eligible for its ordinary PR route. + +**Journey and architecture.** The description adapter maps read failures to +semantic provider/authorization errors. The configuration reader performs strict +parsing/validation and throws `configuration.invalid` with one closed recovery +descriptor: `stored-configuration-schema-missing`, +`stored-configuration-schema-unsupported`, or `stored-configuration-invalid`. +Only a positive safe issue/PR number and expected version, plus a nonnegative +safe numeric actual version for the unsupported case, cross this boundary. +Malformed JSON, values, fields and incomplete delimiters use the invalid +descriptor without copying body/schema text. + +```text +Read issue/PR -> validate stored block -> fail with safe configuration facts + -> localized annotation + Job Summary -> maintainer correction -> rerun +``` + +The flow reads first, rejects unsupported state before PR/issue route mutations, +and exposes a manual recovery action. The final Action boundary MUST preserve +code, nonretryability and correlation identity. It uses existing presentation and +summary ports even when execution setup never completed. Summary publication +failure MUST NOT replace the original failed annotation or turn the run into +success. Existing architecture/taint checks enforce the application/model/adapter +boundaries; raw causes remain private. + +**Configuration and security.** No setting, schema version change, compatibility +reader, automatic migration/reset, workflow, setup step or extra comment is +introduced. Supported state and absent blocks keep their existing behavior. +The failure MUST leave the issue/PR body and configuration untouched. Missing, +old, future and malformed versions MUST remain failed and nonretryable without +correction. A version number alone never establishes valid managed state. +Provider failures retain their own categories and retryability. Closed variable +validation excludes arbitrary text, URLs, credentials and extra fields. + +**GitHub content and recovery.** English and Spanish catalogs cover all three +diagnoses. Other early locales use the established atomic fallback without a +new model call. A fixed recovery URL is inserted by presentation after +localization, so translations cannot supply URLs and remain subject to existing +safe-text validation. Representative missing-schema content: + +```text +Impact: Automation stopped for issue/PR #144: the stored configuration has no +schema version; version 3 is required. +Error code: configuration.invalid +Action: A maintainer must review the recovery guide before correcting the block. +Retained state: The issue/PR description and stored configuration were not changed by this read. +Retryable: No +Reference: +``` + +The complete action links to +[recovery](/security-operations/operations/troubleshooting#unsupported-stored-configuration) +and requires preservation of the original body before removing only a verified +obsolete unmanaged block. If managed branch, release, hotfix or orchestration +state may exist, the operator MUST stop for maintainer review and verified +supported-state restoration. It MUST NOT advise blindly adding a schema version, +deleting managed state, or skipping a failed check. Repeating unchanged invalid +state remains failed without writes. After verified obsolete-marker removal, +the real unlinked setup route MUST configure without inventing an issue link. + +Pending/completed states retain their existing presentation. Partial mutation, +new cancellation/race machinery and navigation registration are not applicable +to this early read failure. Rollout changes only diagnostics; rollback reverts +the diagnostic change while preserving strict validation. Existing in-flight +managed state is never automatically rewritten. + +**Verification floor: 15 distinct cases**, owned only by this extension: + +| Area | Cases | Acceptance/evidence | +|---|---:|---| +| Payload/routing | 6 | absent block, missing version, old version, future version, malformed JSON, valid current state | +| Propagation/provider | 3 | final annotation/summary preserve code/reference/retry; authorization and transient reads retain their own categories | +| Presentation/security | 4 | English, Spanish/fallback, hostile/oversized text excluded, no blind managed-state removal/version edits | +| Recovery/replay | 2 | unchanged replay fails without writes; deliberate obsolete-marker removal recovers an unlinked PR | + +`configuration_recovery.test.ts` owns payload and real setup/replay evidence; +`github_action.test.ts` owns annotation/summary/reference/fallback and summary +failure evidence; issue-content adapter and error/presentation suites own +provider mapping and bounded descriptor tests. Additional malformed-value, +marker, forged-variable and mapping cases do not reduce this floor. +Assertions inspect structured semantics and absence of mutation, not only +snapshots. Existing repository and changed-module coverage thresholds, type +checking, architecture/taint guards, documentation and specification validation +remain required. Inspect a narrow annotation/Job Summary and its recovery link +for readability; native replay of the new diagnostic remains a separate live +verification step until the change is deployed. + +**Documentation and delivery.** Update configuration persistence, error reference +and troubleshooting (including a stable recovery heading), plus the execution +catalog evidence and generated catalog. The implementation sequence is closed +facts/reader, final boundary and localization, adapter mapping, deterministic +tests, docs/catalog, then validation. Done requires all acceptance rows to pass, +generated evidence to be current and strict rejection to remain intact. diff --git a/src/actions/__tests__/github_action.test.ts b/src/actions/__tests__/github_action.test.ts index ecf959668..898a1ebfb 100644 --- a/src/actions/__tests__/github_action.test.ts +++ b/src/actions/__tests__/github_action.test.ts @@ -677,6 +677,49 @@ describe('runGitHubActionEntry', () => { jest.clearAllMocks(); }); + it.each([ + ['en-US', '{"branchType":"feature"}', 'has no schema version', 'Retryable: No'], + ['es-MX', '{"schemaVersion":2}', 'versión de esquema almacenada 2', 'Reintentable: No'], + ['fr-FR', '{"schemaVersion":99}', 'stored schema version 99', 'Retryable: No'], + ])('preserves real stored-configuration diagnostics in the annotation and summary for %s', async (locale, json, causeText, retryText) => { + (core.getInput as jest.Mock).mockImplementation((key: string) => key === INPUT_KEYS.REPOSITORY_LOCALE ? locale : ''); + const { ConfigurationHandler: RealHandler } = jest.requireActual('../../manager/description/configuration_handler'); + const updateDescription = jest.fn(); + const reader = new RealHandler({ + getDescription: jest.fn(async () => ''), + updateDescription, + }); + const failure = await reader.get({ owner: 'o', repository: 'r', issueNumber: 144, token: 'fixture' }).catch(error => error); + await runGitHubActionEntry(async () => { throw failure; }); + const annotation = (core.setFailed as jest.Mock).mock.calls[0][0] as string; + const markdown = (core.summary.addRaw as jest.Mock).mock.calls[0][0] as string; + for (const surface of [annotation, markdown]) { + expect(surface).toContain(causeText); + expect(surface).toContain('144'); + expect(surface).toContain('configuration.invalid'); + expect(surface).toContain(failure.correlationId); + expect(surface).toContain('#unsupported-stored-configuration'); + expect(surface).not.toContain('workflow.failed'); + expect(surface).not.toContain(json); + } + expect(annotation).toContain(retryText); + expect(core.summary.write).toHaveBeenCalledTimes(1); + expect(core.setFailed).toHaveBeenCalledTimes(1); + expect(updateDescription).not.toHaveBeenCalled(); + }); + + it('keeps the configuration failure when summary publication fails', async () => { + (core.getInput as jest.Mock).mockReturnValue(''); + (core.summary.write as jest.Mock).mockRejectedValueOnce(new Error('PRIVATE-SUMMARY-FAILURE')); + const failure = new ApplicationError('configuration.invalid', 'Safe detail.', { + recovery: { id: 'stored-configuration-schema-missing', variables: { issueNumber: 144, expectedVersion: 3 } }, + }); + await expect(runGitHubActionEntry(async () => { throw failure; })).resolves.toBeUndefined(); + expect(core.setFailed).toHaveBeenCalledTimes(1); + expect(core.setFailed).toHaveBeenCalledWith(expect.stringContaining('configuration.invalid')); + expect(core.setFailed).not.toHaveBeenCalledWith(expect.stringContaining('PRIVATE-SUMMARY-FAILURE')); + }); + it('lets Node preserve an existing failure exit code after a resolved run', async () => { const previousExitCode = process.exitCode; process.exitCode = 1; diff --git a/src/actions/github_action.ts b/src/actions/github_action.ts index 4baae0618..e94c26acb 100644 --- a/src/actions/github_action.ts +++ b/src/actions/github_action.ts @@ -23,7 +23,7 @@ import { activeAgentTasks, isUnaddressedCommentEvent } from '../application/poli import { createActorAuthorizationRepository } from '../infrastructure/composition/actor_authorization_composition_root'; import { runAtApplicationErrorBoundary } from '../application/errors/application_error_context'; import { toApplicationError } from '../application/errors/application_error'; -import { renderApplicationErrorText } from '../application/policies/application_error_presentation_policy'; +import { renderApplicationErrorMarkdown, renderApplicationErrorText } from '../application/policies/application_error_presentation_policy'; import { bindIssueCommentPublication } from '../infrastructure/composition/push_single_action_capability_port_binding'; import { bindPublicationSourceQuery } from '../infrastructure/composition/shared_capability_port_binding'; import { GithubPublicationSourceRepository } from '../data/repository/github_publication_source_repository'; @@ -230,7 +230,15 @@ export async function runGitHubActionEntry( } catch (cause: unknown) { const semanticError = toApplicationError(cause, 'workflow.failed', 'GitHub Action execution failed.'); logError(semanticError); - core.setFailed(renderApplicationErrorText(semanticError, earlyGithubActionErrorMessage())); + const message = earlyGithubActionErrorMessage(); + core.setFailed(renderApplicationErrorText(semanticError, message)); + if (semanticError.recovery?.id.startsWith('stored-configuration-')) { + try { + await createGithubActionSummaryCompositionRoot().publish(renderApplicationErrorMarkdown(semanticError, message)); + } catch (summaryCause) { + logError(toApplicationError(summaryCause, 'provider.unavailable', 'Unable to publish the configuration failure summary.')); + } + } } }); } diff --git a/src/application/errors/__tests__/application_error.test.ts b/src/application/errors/__tests__/application_error.test.ts index b6ce587c3..fb585a96c 100644 --- a/src/application/errors/__tests__/application_error.test.ts +++ b/src/application/errors/__tests__/application_error.test.ts @@ -9,6 +9,18 @@ import { runAtApplicationErrorBoundary, runWithApplicationErrorCorrelation } fro const CORRELATION_ID = '6f173f89-96a3-4fc8-b90e-4f8f48e0e319'; describe('ApplicationError', () => { + it.each([ + { id: 'stored-configuration-schema-missing', variables: { issueNumber: 0, expectedVersion: 3 } }, + { id: 'stored-configuration-invalid', variables: { issueNumber: 144, expectedVersion: 0 } }, + { id: 'stored-configuration-schema-unsupported', variables: { issueNumber: 144, expectedVersion: 3, actualVersion: 'PRIVATE' } }, + { id: 'stored-configuration-schema-unsupported', variables: { issueNumber: 144, expectedVersion: 3, actualVersion: -1 } }, + { id: 'stored-configuration-schema-unsupported', variables: { issueNumber: 144, expectedVersion: 3, actualVersion: Number.MAX_SAFE_INTEGER + 1 } }, + { id: 'stored-configuration-schema-missing', variables: { issueNumber: 144, expectedVersion: 3, body: 'PRIVATE' } }, + ])('rejects forged stored-configuration facts: $id $variables', recovery => { + expect(() => new ApplicationError('configuration.invalid', 'Invalid state.', { + recovery: recovery as never, + })).toThrow(); + }); it('rejects a forged branch synchronization diagnostic descriptor', () => { expect(() => new ApplicationError('workflow.failed', 'private', { recovery: { id: 'branch-sync-resolution-invalid', variables: { validationCode: 'TOKEN=secret', command: '/copilot sync-branch' } } as never, diff --git a/src/application/policies/__tests__/application_error_presentation_policy.test.ts b/src/application/policies/__tests__/application_error_presentation_policy.test.ts index 8b4c007fe..1318ac91e 100644 --- a/src/application/policies/__tests__/application_error_presentation_policy.test.ts +++ b/src/application/policies/__tests__/application_error_presentation_policy.test.ts @@ -19,6 +19,13 @@ const RECOVERY_CASES: readonly Readonly<{ english: string; spanish: string; }>[] = [ + ...(['stored-configuration-schema-missing', 'stored-configuration-schema-unsupported', 'stored-configuration-invalid'] as const).map(id => ({ + recovery: id === 'stored-configuration-schema-unsupported' + ? { id, variables: { issueNumber: 144, expectedVersion: 3, actualVersion: 2 } } + : { id, variables: { issueNumber: 144, expectedVersion: 3 } }, + english: 'The issue/PR description and stored configuration were not changed by this read.', + spanish: 'Esta lectura no modificó la descripción de la issue/PR ni su configuración almacenada.', + })), { recovery: { id: 'closed-issue-reconciliation-failed', variables: { resultId: 'RetireProgressUseCase', phase: 'card-verification', state: 'closed', reason: 'completed', changed: 1, remaining: 1 } }, english: 'Native state was preserved. Confirmed changes: 1; remaining required changes in the last verified plan: 1. Unavailable reads do not establish completion.', diff --git a/src/application/policies/application_error_message_catalog.ts b/src/application/policies/application_error_message_catalog.ts index 9b8d7c0d6..e5d244305 100644 --- a/src/application/policies/application_error_message_catalog.ts +++ b/src/application/policies/application_error_message_catalog.ts @@ -205,7 +205,27 @@ const SPANISH_CONTENT: ErrorContent = Object.freeze({ }), }); +const STORED_CONFIGURATION_ACTION_EN = 'A maintainer must review {recoveryGuide}. Preserve a copy before removing only a verified obsolete unmanaged block. If managed branch, release, hotfix or orchestration state may exist, stop and restore verified supported state with a maintainer. Do not just add a schema version or delete managed state. Correct the configuration before rerunning; retrying unchanged state will fail again.'; +const STORED_CONFIGURATION_ACTION_ES = 'Un mantenedor debe revisar {recoveryGuide}. Conserva una copia antes de retirar solo un bloque obsoleto confirmado como no gestionado. Si puede existir estado gestionado de ramas, releases, hotfixes u orquestación, detente y restaura estado compatible verificado con un mantenedor. No añadas simplemente una versión de esquema ni borres estado gestionado. Corrige la configuración antes de reejecutar; el mismo estado volverá a fallar.'; +const STORED_CONFIGURATION_RETAINED_EN = 'The issue/PR description and stored configuration were not changed by this read.'; +const STORED_CONFIGURATION_RETAINED_ES = 'Esta lectura no modificó la descripción de la issue/PR ni su configuración almacenada.'; + const ENGLISH_RECOVERY_CONTENT: RecoveryContent = Object.freeze({ + 'stored-configuration-schema-missing': Object.freeze({ + impact: 'Automation stopped for issue/PR #{issueNumber}: the stored configuration has no schema version; version {expectedVersion} is required.', + action: STORED_CONFIGURATION_ACTION_EN, + retainedState: STORED_CONFIGURATION_RETAINED_EN, + }), + 'stored-configuration-schema-unsupported': Object.freeze({ + impact: 'Automation stopped for issue/PR #{issueNumber}: stored schema version {actualVersion} is unsupported; version {expectedVersion} is required.', + action: STORED_CONFIGURATION_ACTION_EN, + retainedState: STORED_CONFIGURATION_RETAINED_EN, + }), + 'stored-configuration-invalid': Object.freeze({ + impact: 'Automation stopped for issue/PR #{issueNumber}: the stored configuration, markers or schema value are malformed; valid schema version {expectedVersion} is required.', + action: STORED_CONFIGURATION_ACTION_EN, + retainedState: STORED_CONFIGURATION_RETAINED_EN, + }), 'closed-issue-reconciliation-failed': Object.freeze({ impact: 'Issue cleanup remains unverified: {resultId}, phase {phase}; native state {state}, reason {reason}.', action: 'Inspect the closed-issue or merged-PR run and current native state. Retry cleanup only if the issue is still closed with the intended reason; leave reopened work open. Resolve the named failure before retrying.', @@ -285,6 +305,21 @@ const ENGLISH_RECOVERY_CONTENT: RecoveryContent = Object.freeze({ }); const SPANISH_RECOVERY_CONTENT: RecoveryContent = Object.freeze({ + 'stored-configuration-schema-missing': Object.freeze({ + impact: 'La automatización se detuvo para la issue/PR #{issueNumber}: la configuración almacenada no tiene versión de esquema; se requiere la versión {expectedVersion}.', + action: STORED_CONFIGURATION_ACTION_ES, + retainedState: STORED_CONFIGURATION_RETAINED_ES, + }), + 'stored-configuration-schema-unsupported': Object.freeze({ + impact: 'La automatización se detuvo para la issue/PR #{issueNumber}: la versión de esquema almacenada {actualVersion} no es compatible; se requiere la versión {expectedVersion}.', + action: STORED_CONFIGURATION_ACTION_ES, + retainedState: STORED_CONFIGURATION_RETAINED_ES, + }), + 'stored-configuration-invalid': Object.freeze({ + impact: 'La automatización se detuvo para la issue/PR #{issueNumber}: la configuración almacenada, sus delimitadores o el valor del esquema no son válidos; se requiere un esquema válido de versión {expectedVersion}.', + action: STORED_CONFIGURATION_ACTION_ES, + retainedState: STORED_CONFIGURATION_RETAINED_ES, + }), 'closed-issue-reconciliation-failed': Object.freeze({ impact: 'La limpieza de la issue sigue sin verificarse: {resultId}, fase {phase}; estado nativo {state}, motivo {reason}.', action: 'Revisa la ejecución de cierre o de la PR fusionada y el estado nativo actual. Reintenta la limpieza solo si la issue sigue cerrada con el motivo previsto; conserva abierto el trabajo reabierto. Resuelve el fallo indicado antes de reintentar.', diff --git a/src/application/policies/application_error_presentation_policy.ts b/src/application/policies/application_error_presentation_policy.ts index 5f60eec6d..f556a4972 100644 --- a/src/application/policies/application_error_presentation_policy.ts +++ b/src/application/policies/application_error_presentation_policy.ts @@ -7,6 +7,9 @@ import { type ApplicationErrorMessageReader, } from './application_error_message_catalog'; +// This trusted destination is inserted after localization, never supplied by a body or translation. +const STORED_CONFIGURATION_RECOVERY_URL = 'https://docs.page/vypdev/copilot/security-operations/operations/troubleshooting#unsupported-stored-configuration'; + export interface ApplicationErrorPresentation { readonly impact: string; readonly code: string; @@ -31,7 +34,9 @@ export function buildApplicationErrorPresentation( const descriptor = error.recovery ? `error.recovery.${error.recovery.id}` as const : `error.${error.code}` as const; - const variables = error.recovery?.variables; + const variables = error.recovery?.id.startsWith('stored-configuration-') + ? { ...error.recovery.variables, recoveryGuide: STORED_CONFIGURATION_RECOVERY_URL } + : error.recovery?.variables; return { impact: message(`${descriptor}.impact`, variables), code: error.code, diff --git a/src/data/model/application_error.ts b/src/data/model/application_error.ts index 03eabf60d..274af0a25 100644 --- a/src/data/model/application_error.ts +++ b/src/data/model/application_error.ts @@ -53,12 +53,18 @@ export const APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'workspace-changes-rejected', 'closed-issue-reconciliation-failed', 'approval-evidence-read-failed', + 'stored-configuration-schema-missing', + 'stored-configuration-schema-unsupported', + 'stored-configuration-invalid', ] as const); export type ApplicationErrorRecoveryId = typeof APPLICATION_ERROR_RECOVERY_IDS[number]; interface ApplicationErrorRecoveryVariables { + readonly 'stored-configuration-schema-missing': Readonly<{ issueNumber: number; expectedVersion: number }>; + readonly 'stored-configuration-schema-unsupported': Readonly<{ issueNumber: number; expectedVersion: number; actualVersion: number }>; + readonly 'stored-configuration-invalid': Readonly<{ issueNumber: number; expectedVersion: number }>; readonly 'closed-issue-reconciliation-failed': ClosedIssueReconciliationFacts; readonly 'approval-evidence-read-failed': ApprovalEvidenceDiagnostic; @@ -316,6 +322,9 @@ export class ApplicationError extends Error { } const RECOVERY_VARIABLE_KEYS: Readonly> = Object.freeze({ + 'stored-configuration-schema-missing': Object.freeze(['issueNumber', 'expectedVersion']), + 'stored-configuration-schema-unsupported': Object.freeze(['issueNumber', 'expectedVersion', 'actualVersion']), + 'stored-configuration-invalid': Object.freeze(['issueNumber', 'expectedVersion']), 'closed-issue-reconciliation-failed': Object.freeze(['resultId', 'phase', 'state', 'reason', 'changed', 'remaining']), 'approval-evidence-read-failed': Object.freeze(['operation', 'reason', 'permission']), @@ -348,6 +357,14 @@ function normalizeApplicationErrorRecovery( || actualKeys.some((key, index) => key !== expectedKeys[index])) { throw new TypeError(`Application error recovery variables are invalid for ${recovery.id}.`); } + if (recovery.id.startsWith('stored-configuration-') + && (![variables.issueNumber, variables.expectedVersion].every(value => + typeof value === 'number' && Number.isSafeInteger(value) && value > 0) + || (recovery.id === 'stored-configuration-schema-unsupported' + && !(typeof variables.actualVersion === 'number' + && Number.isSafeInteger(variables.actualVersion) && variables.actualVersion >= 0)))) { + throw new TypeError('Stored configuration diagnostic is invalid.'); + } if (recovery.id === 'managed-branch-enrichment-failed' && (typeof variables.branchName !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$/u.test(variables.branchName))) { diff --git a/src/data/repository/github/github_error_policy.ts b/src/data/repository/github/github_error_policy.ts index 6a888fbb6..e6231538a 100644 --- a/src/data/repository/github/github_error_policy.ts +++ b/src/data/repository/github/github_error_policy.ts @@ -1,3 +1,5 @@ +import type { ApplicationErrorCode } from '../../model/application_error'; + export interface GithubErrorShape { status?: number; message?: string; @@ -12,6 +14,18 @@ export const getGithubErrorStatus = (error: unknown): number | undefined => { export const isGithubNotFound = (error: unknown): boolean => getGithubErrorStatus(error) === 404; +/** Maps failed metadata reads without copying untrusted provider text. */ +export function githubReadErrorCode(error: unknown): ApplicationErrorCode { + const status = getGithubErrorStatus(error); + if (status === 401) return 'authorization.credential-invalid'; + if (isGithubRateLimited(error)) return 'provider.rate-limited'; + if (status === 403) return 'authorization.denied'; + if (status === 404) return 'provider.not-found'; + if (status === 409 || status === 412) return 'provider.conflict'; + if (status === 400 || status === 422) return 'provider.contract-invalid'; + return 'provider.unavailable'; +} + export const isGithubRateLimited = (error: unknown): boolean => { const status = getGithubErrorStatus(error); if (status === 429) return true; diff --git a/src/data/repository/issue/__tests__/issue_content_repository.test.ts b/src/data/repository/issue/__tests__/issue_content_repository.test.ts index 1e2b66a77..572083314 100644 --- a/src/data/repository/issue/__tests__/issue_content_repository.test.ts +++ b/src/data/repository/issue/__tests__/issue_content_repository.test.ts @@ -53,7 +53,34 @@ describe('IssueContentRepository', () => { it('preserves issue access failures instead of presenting them as an empty description', async () => { mockGet.mockRejectedValue(new Error('Not Found')); - await expect(repository.getDescription('owner', 'repo', 7, 'token')).rejects.toThrow('Not Found'); + await expect(repository.getDescription('owner', 'repo', 7, 'token')).rejects.toMatchObject({ + code: 'provider.unavailable', retryable: true, + }); + }); + + it.each([ + [401, 'authorization.credential-invalid', false], + [403, 'authorization.denied', false], + [404, 'provider.not-found', false], + [409, 'provider.conflict', true], + [412, 'provider.conflict', true], + [400, 'provider.contract-invalid', false], + [422, 'provider.contract-invalid', false], + [429, 'provider.rate-limited', true], + [503, 'provider.unavailable', true], + ])('maps description-read HTTP %s to %s without exposing provider text', async (status, code, retryable) => { + mockGet.mockRejectedValue({ status, message: 'PRIVATE-PROVIDER-BODY' }); + const error = await repository.getDescription('owner', 'repo', 7, 'token').catch(error => error); + expect(error).toMatchObject({ code, retryable }); + expect(JSON.stringify(error)).not.toContain('PRIVATE-PROVIDER-BODY'); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('preserves rate-limit semantics for a 403 description read', async () => { + mockGet.mockRejectedValue({ status: 403, response: { headers: { 'x-ratelimit-remaining': '0' } } }); + await expect(repository.getDescription('owner', 'repo', 7, 'token')).rejects.toMatchObject({ + code: 'provider.rate-limited', retryable: true, + }); }); it('publishes exactly the semantic body without a visible branding footer', async () => { diff --git a/src/data/repository/issue/issue_content_repository.ts b/src/data/repository/issue/issue_content_repository.ts index 02e1092bc..184aaf253 100644 --- a/src/data/repository/issue/issue_content_repository.ts +++ b/src/data/repository/issue/issue_content_repository.ts @@ -7,7 +7,7 @@ import type { } from "../../../infrastructure/github/ports/github_issue_provider_ports"; import { requireArrayPage } from "../github/github_pagination_policy"; import { toApplicationError } from '../../../application/errors/application_error'; -import { getGithubErrorStatus, isGithubPermissionDenied } from '../github/github_error_policy'; +import { getGithubErrorStatus, githubReadErrorCode, isGithubPermissionDenied } from '../github/github_error_policy'; import type { IssueCommentRemovalOutcome } from '../../../application/ports/issue_lifecycle_ports'; export interface IssueComment { @@ -58,8 +58,9 @@ export class IssueContentRepository { }); return issue.body ?? ''; } catch (error) { - logError(toApplicationError(error, 'provider.unavailable', `Unable to read issue #${issueNumber} description.`)); - throw error; + const semanticError = toApplicationError(error, githubReadErrorCode(error), 'Unable to read the issue or pull-request description.'); + logError(semanticError); + throw semanticError; } }; diff --git a/src/manager/description/__tests__/configuration_handler.test.ts b/src/manager/description/__tests__/configuration_handler.test.ts index 773db0a72..9a860fd61 100644 --- a/src/manager/description/__tests__/configuration_handler.test.ts +++ b/src/manager/description/__tests__/configuration_handler.test.ts @@ -261,9 +261,12 @@ describe('ConfigurationHandler', () => { expect(result).toBeUndefined(); }); - it('get throws informative error on invalid JSON', async () => { + it('get rejects invalid JSON through the safe configuration error contract', async () => { mockGetDescription.mockResolvedValue(descriptionWithConfig('{ "broken": ')); - await expect(handler.get(configurationQuery())).rejects.toThrow(/Unexpected end of JSON input|SyntaxError/); + await expect(handler.get(configurationQuery())).rejects.toMatchObject({ + code: 'configuration.invalid', retryable: false, + recovery: { id: 'stored-configuration-invalid' }, + }); }); }); }); diff --git a/src/manager/description/__tests__/configuration_recovery.test.ts b/src/manager/description/__tests__/configuration_recovery.test.ts new file mode 100644 index 000000000..a72a05c31 --- /dev/null +++ b/src/manager/description/__tests__/configuration_recovery.test.ts @@ -0,0 +1,147 @@ +import { ConfigurationHandler } from '../configuration_handler'; +import { ApplicationError } from '../../../application/errors/application_error'; +import { runWithApplicationErrorCorrelation } from '../../../application/errors/application_error_context'; +import { renderApplicationErrorText } from '../../../application/policies/application_error_presentation_policy'; +import { resolveStaticApplicationErrorCatalog } from '../../../application/policies/application_error_message_catalog'; +import { runSetupExecution } from '../../../application/usecases/execution/setup_execution_workflow'; +import type { SetupExecutionContext } from '../../../application/usecases/execution/setup_execution_contracts'; + +jest.mock('../../../utils/logger', () => ({ logError: jest.fn() })); + +const REFERENCE = '6f173f89-96a3-4fc8-b90e-4f8f48e0e319'; +const query = { owner: 'fixture', repository: 'fixture', issueNumber: 144, token: 'fixture' }; +const block = (json: string) => 'Human PR body\n'; + +function fixture(initialBody: string) { + let body = initialBody; + const getDescription = jest.fn(async () => body); + const updateDescription = jest.fn(); + const handler = new ConfigurationHandler({ getDescription, updateDescription }); + return { handler, getDescription, updateDescription, setBody: (next: string) => { body = next; } }; +} + +function unlinkedPrContext(): SetupExecutionContext { + return { + debug: false, local: false, tokenUser: 'fixture-user', issueNumber: -1, + eventName: 'pull_request', isSingleAction: false, isIssue: false, isPullRequest: true, isPush: false, + issue: { number: -1 }, pullRequest: { number: 144, head: 'codex/integrity-audit', base: 'develop' }, + commit: { branch: '' }, + singleAction: { issue: -1, currentAction: '', isIssue: false, isPullRequest: false, isPush: false }, + branches: { featureTree: 'feature', bugfixTree: 'bugfix', hotfixTree: 'hotfix', releaseTree: 'release', docsTree: 'docs', choreTree: 'chore' }, + labelNames: { feature: 'feature', enhancement: 'enhancement', bugfix: 'bugfix', bug: 'bug', hotfix: 'hotfix', release: 'release', docs: 'docs', documentation: 'documentation', chore: 'chore', maintenance: 'maintenance' }, + currentPullRequestLabels: [], release: { active: false }, hotfix: { active: false }, + }; +} + +describe('stored configuration diagnostics and recovery (#635)', () => { + it.each([ + ['missing schema', '{"branchType":"feature"}', 'stored-configuration-schema-missing', undefined], + ['old schema', '{"schemaVersion":2,"branchType":"feature"}', 'stored-configuration-schema-unsupported', 2], + ['future schema', '{"schemaVersion":99,"branchType":"feature"}', 'stored-configuration-schema-unsupported', 99], + ['malformed JSON', '{"schemaVersion":', 'stored-configuration-invalid', undefined], + ])('rejects %s with safe nonretryable facts and no writes', async (_name, json, id, actualVersion) => { + const f = fixture(block(json as string)); + const error = await runWithApplicationErrorCorrelation(REFERENCE, () => f.handler.get(query).catch(error => error)); + expect(error).toBeInstanceOf(ApplicationError); + expect(error).toMatchObject({ + code: 'configuration.invalid', retryable: false, correlationId: REFERENCE, + recovery: { id, variables: { issueNumber: 144, expectedVersion: 3, ...(actualVersion === undefined ? {} : { actualVersion }) } }, + }); + expect(f.getDescription).toHaveBeenCalledWith('fixture', 'fixture', 144, 'fixture'); + expect(f.updateDescription).not.toHaveBeenCalled(); + }); + + it('keeps an absent block distinct from an invalid stored block', async () => { + const f = fixture('Human PR body without stored state'); + await expect(f.handler.get(query)).resolves.toBeUndefined(); + expect(f.updateDescription).not.toHaveBeenCalled(); + }); + + it('continues reading supported state without rewriting it', async () => { + const f = fixture(block('{"schemaVersion":3,"branchType":"feature","workingBranch":"feature/42-work"}')); + await expect(f.handler.get(query)).resolves.toMatchObject({ schemaVersion: 3, workingBranch: 'feature/42-work' }); + expect(f.updateDescription).not.toHaveBeenCalled(); + }); + + it.each([ + 'null', '[]', '{"schemaVersion":"3"}', '{"schemaVersion":-1}', '{"schemaVersion":1.5}', + '{"schemaVersion":1e99}', '{"schemaVersion":3,"recommendationState":{"invalid":true}}', + ])('rejects malformed payload %s without exposing its contents', async json => { + const f = fixture(block(json)); + await expect(f.handler.get(query)).rejects.toMatchObject({ + code: 'configuration.invalid', retryable: false, recovery: { id: 'stored-configuration-invalid' }, + }); + expect(f.updateDescription).not.toHaveBeenCalled(); + }); + + it.each([ + 'Human body\n', + ])('rejects incomplete markers as invalid state rather than an absent block', async body => { + const f = fixture(body); + await expect(f.handler.get(query)).rejects.toMatchObject({ + code: 'configuration.invalid', recovery: { id: 'stored-configuration-invalid' }, + }); + expect(f.updateDescription).not.toHaveBeenCalled(); + }); + + it.each(['authorization.denied', 'provider.unavailable'] as const)('preserves an originating %s read failure and its reference', async code => { + const f = fixture('unread'); + const original = new ApplicationError(code, 'Safe provider failure.', { correlationId: REFERENCE }); + f.getDescription.mockRejectedValue(original); + await expect(f.handler.get(query)).rejects.toBe(original); + expect(original.recovery).toBeUndefined(); + expect(f.updateDescription).not.toHaveBeenCalled(); + }); + + it.each(['en-US', 'es-MX'])('shows the cause and safe recovery in %s without leaking schema/body text', async locale => { + const secret = 'PRIVATE-BODY-::error::' + 'x'.repeat(10000); + const f = fixture(block(JSON.stringify({ schemaVersion: secret, token: secret }))); + const error = await f.handler.get(query).catch(error => error); + const text = renderApplicationErrorText(error, resolveStaticApplicationErrorCatalog(locale).message); + expect(text).toContain('144'); + expect(text).toContain(locale === 'en-US' ? 'version 3' : 'versión 3'); + expect(text).toContain('configuration.invalid'); + expect(text).toContain(locale === 'en-US' ? 'Retryable: No' : 'Reintentable: No'); + expect(text).toContain('#unsupported-stored-configuration'); + expect(text).toContain(locale === 'en-US' ? 'Do not just add a schema version or delete managed state' : 'No añadas simplemente una versión de esquema ni borres estado gestionado'); + expect(text).toContain(locale === 'en-US' ? 'stop and restore verified supported state' : 'detente y restaura estado compatible verificado'); + expect(text).not.toContain('PRIVATE-BODY'); + expect(JSON.stringify(error)).not.toContain('PRIVATE-BODY'); + expect(text.length).toBeLessThan(1800); + }); + + it('keeps an unchanged invalid PR failed on replay without writes or fabricated linkage', async () => { + const f = fixture(block('{"branchType":"feature"}')); + for (let attempt = 0; attempt < 2; attempt++) { + await expect(f.handler.get(query)).rejects.toMatchObject({ code: 'configuration.invalid', retryable: false }); + } + expect(f.getDescription).toHaveBeenCalledTimes(2); + expect(f.updateDescription).not.toHaveBeenCalled(); + }); + + it('lets verified obsolete-marker removal recover the real unlinked setup route', async () => { + const f = fixture(block('{"branchType":"feature"}')); + const issuePort = { + isPullRequest: jest.fn(), isIssue: jest.fn(), getHeadBranch: jest.fn(), getLabels: jest.fn(async () => []), + getDescription: jest.fn(), getTitle: jest.fn(), + }; + const dependencies = { + issueSetupPort: issuePort, organizationSetupPort: { getTokenUser: jest.fn() }, + configurationPort: { get: (issueNumber: number) => f.handler.get({ ...query, issueNumber }) }, + branchVersionResolver: { resolve: jest.fn() }, + }; + await expect(runSetupExecution(unlinkedPrContext(), dependencies)).rejects.toMatchObject({ code: 'configuration.invalid' }); + f.setBody('Human PR body preserved after a maintainer verified and removed only the obsolete block.'); + const result = await runSetupExecution(unlinkedPrContext(), dependencies); + expect(result).toMatchObject({ + status: 'configured', + state: { previousConfiguration: undefined, configuration: { workingBranch: 'codex/integrity-audit', parentBranch: 'develop' } }, + }); + expect(result.issueResolution.issueNumber).toBeUndefined(); + expect(issuePort.isIssue).not.toHaveBeenCalled(); + expect(issuePort.getDescription).not.toHaveBeenCalled(); + expect(dependencies.branchVersionResolver.resolve).not.toHaveBeenCalled(); + expect(f.updateDescription).not.toHaveBeenCalled(); + }); +}); diff --git a/src/manager/description/configuration_handler.ts b/src/manager/description/configuration_handler.ts index 4bd1e5ca9..67735a917 100644 --- a/src/manager/description/configuration_handler.ts +++ b/src/manager/description/configuration_handler.ts @@ -1,10 +1,10 @@ -import { Config, requireCurrentConfigurationPayload } from "../../data/model/config"; +import { CONFIG_SCHEMA_VERSION, Config, requireCurrentConfigurationPayload } from "../../data/model/config"; import type { ExecutionConfigurationQuery } from "../../application/ports/execution_configuration_ports"; import type { ConfigurationPersistenceContext } from '../../application/ports/configuration_store_ports'; import { logError } from "../../utils/logger"; import { IssueContentInterface } from "./base/issue_content_interface"; import { buildConfigurationPayload } from './configuration_payload_policy'; -import { toApplicationError } from '../../application/errors/application_error'; +import { toApplicationError, type ApplicationErrorRecovery } from '../../application/errors/application_error'; export class ConfigurationHandler extends IssueContentInterface { @@ -22,22 +22,47 @@ export class ConfigurationHandler extends IssueContentInterface { } get = async (query: ExecutionConfigurationQuery): Promise => { + let description: string | undefined; try { - const description = await this.issueDescriptionPort.getDescription( + description = await this.issueDescriptionPort.getDescription( query.owner, query.repository, query.issueNumber, query.token, ); + } catch (cause) { + const semanticError = toApplicationError(cause, 'provider.unavailable', 'Unable to read issue configuration.'); + logError(semanticError); + throw semanticError; + } + + const variables = { issueNumber: query.issueNumber, expectedVersion: CONFIG_SCHEMA_VERSION }; + let recovery: ApplicationErrorRecovery = { id: 'stored-configuration-invalid', variables }; + try { const config = this.getContent(description); if (config === undefined) { + if (description?.includes('')) { + throw new Error('Stored configuration markers are inconsistent.'); + } return undefined; } - const branchConfig = requireCurrentConfigurationPayload(JSON.parse(config)); + const parsed: unknown = JSON.parse(config); + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { + const version = (parsed as Record).schemaVersion; + if (version === undefined) { + recovery = { id: 'stored-configuration-schema-missing', variables }; + } else if (typeof version === 'number' && Number.isSafeInteger(version) + && version >= 0 && version !== CONFIG_SCHEMA_VERSION) { + recovery = { id: 'stored-configuration-schema-unsupported', variables: { ...variables, actualVersion: version } }; + } + } + const branchConfig = requireCurrentConfigurationPayload(parsed); return new Config(branchConfig); - } catch (error) { - logError(toApplicationError(error, 'configuration.invalid', 'Unable to read issue configuration.')); - throw error; + } catch (cause) { + const semanticError = toApplicationError(cause, 'configuration.invalid', 'Stored issue/PR configuration is invalid.', { recovery }); + logError(semanticError); + throw semanticError; } } }