diff --git a/.agents/pm/features/pm-github-webhook-inbox.toon b/.agents/pm/features/pm-github-webhook-inbox.toon new file mode 100644 index 00000000..c0f6ce50 --- /dev/null +++ b/.agents/pm/features/pm-github-webhook-inbox.toon @@ -0,0 +1,37 @@ +id: pm-github-webhook-inbox +title: Reconcile authenticated GitHub webhooks through a durable deduplicating inbox +description: "Explicitly supplied issue webhook deliveries are signature verified before durable intake, deduplicated, and reconciled against current provider state into bounded proposals." +type: Feature +status: in_progress +priority: 2 +tags[3]: changelog-unreleased,github,webhooks +created_at: "2026-10-21T20:34:29.731Z" +updated_at: "2026-10-22T11:47:29.084Z" +author: codex-agent +acceptance_criteria: Duplicate and out-of-order events do not regress lifecycle; signature failures cause zero writes; polling refresh remains a recovery route; typed SDK and CLI with compact TOON/JSON refusals and remedies; real public-tracker copy validation without git; npm and Bun CI gates pass. +comments[13]{created_at,author,text}: + "2026-10-21T20:34:36.573Z",codex-agent,"Overlap audit: existing refresh.ts polls only explicitly linked issues and owns update-only import transactions. Installed CLI MCP tool definitions transport pm commands, with no authenticated GitHub event inbox. pm-observer src/ingest.ts and export-adapters.ts ingest agent exports, not provider webhooks. No duplicate feature found by tracker search. PR #164 owns historical tracker reconciliation; its items remain untouched. Design: authenticate exact payload bytes before all writes; store minimal durable delivery hints; reconcile via current-provider refresh previews only; retain receipts and retry failures. Polling refresh remains the apply and recovery route." + "2026-10-21T20:41:00.447Z",codex-agent,"Implementation: authenticated intake stores minimal immutable delivery hints; token-owned writer locking serializes concurrent deduplication; atomically published receipts are file/directory-flushed before acknowledgment. Reconciliation coalesces selected issue numbers and uses read-only current-provider refresh previews, persisting compact outcome receipts without item changes. No receiver is installed. Local targeted suite: 7/7 passing, new webhooks.ts measured 100% statements/branches/functions/lines. No-git acceptance removes git from PATH and sets a discovery ceiling. CI now invokes explicit npm and Bun release:check at unchanged thresholds; full gates run remotely because the host is saturated." + "2026-10-21T20:43:37.315Z",codex-agent,"Behavioral revert proof: removing only signature verification from acceptGithubWebhook makes the signature-failures test exit 1 with Missing expected rejection; restoring it passes. Removing only the runRefresh number-scope predicate makes the stale-event test exit 1 because unrelated issue #2 is queried. Restored both functions. Real public-data acceptance: verified the source repository is public, copied main tracker into a non-git scratch project, made original files read-only, removed git from PATH, and hash-checked 294 original files. Measured 144 items, 5 linked issues, 10 accepted synthetic signed hints and 10 duplicates; 10 reconciled, 0 pending/retry, 5 proposals matching a fresh polling preview. All 294 original file hashes unchanged; compact JSON receipt 3274 bytes. No provider writes." + "2026-10-21T20:44:43.174Z",codex-agent,"The broader local refresh-plus-webhook linked run reached its default 120-second limit under host load after 16 passing refresh scenarios; no assertion failure was reported before termination. Narrowed the linked local acceptance to the seven webhook scenarios (including provider-state scoping and polling apply/recovery), with an explicit 300-second ceiling. Full existing-suite evidence is delegated to CI as requested." + "2026-10-21T20:45:12.776Z",codex-agent,"Local acceptance complete: pm test --run --progress passes the linked webhook command, 7 tests, zero failures/skips/TODOs; typecheck, test typecheck, lint, docstring, zero-duplication and changelog checks pass. New inbox module has exact 100/100/100/100 targeted coverage. Public-data validation and behavioral fault checks are recorded above. Implementation is handed to CI for unchanged-threshold npm/Bun release gates; no merge or release is authorized. Operational limits: local POSIX filesystem inbox, explicit adapter intake, indefinite deduplication retention, proposals must be refreshed before apply." + "2026-10-21T21:10:01.817Z",codex-agent,"Final concurrency review found that the common inbox lock spans provider reads, so slow GitHub access can block authenticated intake until its bounded lock wait expires. Separate the intake and reconciliation lock lanes: immutable atomically published hints permit concurrent intake while processors remain serialized. Add a real HTTP delayed-read regression and behavioral revert proof, then rerun CI on the new head." + "2026-10-21T21:10:54.627Z",codex-agent,"Concurrency regression: withhold the real HTTP provider response, then accept another signed delivery before releasing that response. The separate lock lanes pass; reverting only reconciliation to the shared intake lock exits 1 with inbox_unavailable after contention. Restored the fix. CI npm/Bun release gates now run in parallel jobs, preserving Node 22/26, native Bun acceptance and all existing thresholds." + "2026-10-21T21:14:01.708Z",codex-agent,"Final local result after concurrency hardening: linked pm test passes 9/9, zero failures/skips/TODOs; targeted inbox coverage remains exact 100/100/100/100. Typecheck, test typecheck and lint pass. Separate processor/intake locks keep receipt acceptance available during delayed provider reads. Refresh previews exclude durable inbox storage; reverting only that exclusion makes the opaque-storage regression fail with provider_unavailable. Repeated public-data validation on this code: 144 items, 294/294 original file hashes unchanged, 5 issues, 10 accepted hints, 10 duplicates, 10 reconciled, zero pending/retry, 5 proposals matching polling and a 3274-byte report. The previous CI run was canceled after both npm gates passed because it predates this fix; only the new head will supply final gate evidence." + "2026-10-22T11:12:36.254Z",codex-agent,"PR #165 review round: fetched all two unresolved threads with complete comments and the latest CodeRabbit review; no outside-diff findings. Merged current main before fixes. Verified age-only lock reclamation and retry-prefix starvation against the pinned SDK and implementation. Plan: require explicit recovery for expired inbox locks so live workers cannot be displaced; persist a rotating batch cursor before provider reads, including failed batches; exercise real non-git trackers and delayed HTTP reads, then record behavioral reverts and unchanged npm/Bun release gates." + "2026-10-22T11:15:04.907Z",codex-agent,"Verified both findings and self-reviewed the full PR diff. Applied the pinned SDK forceRequiredForStaleLock guard to both processor and intake locks; it prevents age-only displacement rather than implementing a heartbeat. Documented explicit crash-lock recovery. Added an atomic repository-scoped rotating cursor, advanced before provider I/O so thrown requests also yield to later hints. One directory snapshot keeps queue counts consistent during concurrent intake. Regression fixtures cover missing states, thrown HTTP failures, wrapping, corrupt cursor refusal, aged live processor contention and aged intake locks. Clean-home strict health passes; normal-home strict health detects unrelated globally installed host/extension version skew. No merge receipts are pending." + "2026-10-22T11:15:18.387Z",codex-agent,"Behavioral revert proofs (all restored): reverting only locked acquisition to forceRequiredForStaleLock=false makes node --test --test-name-pattern=\"expired live processor|expired intake\" test/webhooks.test.ts fail 2/2 with Missing expected rejection; a competing processor and intake can steal aged live locks. Reverting only batch selection in reconcileGithubWebhooks to selected.slice(0, limit) makes node --test --test-name-pattern=\"retry batches\" test/webhooks.test.ts fail 1/1: the later ready issue reconciles 0 instead of 1. Moving only cursor persistence past the provider read fails the same regression 1/1, proving thrown batches must advance. Fixed cursor regression passes 1/1. Next: complete Node and Bun gates, unchanged four 100% coverage dimensions." + "2026-10-22T11:27:36.760Z",codex-agent,"Current fixed-code acceptance: pm test pm-github-webhook-inbox --run --progress passes 12/12, zero failures/skips/cancellations/TODOs; bun test --timeout 600000 test/webhooks.test.ts passes 12/12 on Bun 1.3.5. Isolated targeted c8 reports webhooks.ts at exact 100/100/100/100. npm ci, build, test typecheck, lint, docstrings and zero-duplication pass. Clean-home full npm release gate passed privacy and is still executing existing integration scenarios under host contention without failures. Push the completed review fixes to obtain complete clean CI npm/Bun gate evidence; no merge or release. Thread PRRT_kwDOTbN4pM6vALGP fixes age-only processor lock theft, including the same intake defect. Thread PRRT_kwDOTbN4pM6vALGU fixes retry starvation, including thrown provider requests." + "2026-10-22T11:47:27.554Z",codex-agent,"Release-gate receipt for code commit 3e0dbe4a3d079f166275ee44413d7c3b3c04f35a: CI run https://github.com/unbraind/pm-github/actions/runs/39770780741 reports all four required release steps successful: npm run release:check and bun run release:check on Node 22 and 26. Downloaded completed Bun job logs independently confirm 1001/1001 tests on each Node version, zero failures/skips/cancellations/TODOs, and exact 100/100/100/100 across 17 authored modules at unchanged thresholds. Both npm jobs are performing additional native Bun acceptance after their successful complete release gates; their full logs are not yet downloadable. The redundant local clean-home full npm gate was stopped after the four CI release gates passed, with no assertion failures reported before termination; it is not counted as a passing local gate. Local linked PM acceptance and native Bun webhook acceptance both pass 12/12, and targeted inbox coverage is exact 100/100/100/100. Full PR self-review and repeated complete thread fetch found no further findings. Operational risk: crashed inbox workers now require explicitly verified stale-lock recovery; receipts must be retained. Handoff remains in_progress for orchestrator verification; no threads replied to or resolved, no merge and no release." +files[5]{path,scope}: + .github/workflows/ci.yml,project + index.ts,project + refresh.ts,project + test/webhooks.test.ts,project + webhooks.ts,project +tests[1]{command,scope,timeout_seconds,provenance{author,created_at,source_kind,source_ref}}: + node --test test/webhooks.test.ts,project,300,codex-agent,"2026-10-21T20:44:40.939Z",local_mutation,feat/webhook-reconciliation-inbox +docs[2]{path,scope}: + docs/webhook-inbox.md,project + README.md,project +body: "" diff --git a/.agents/pm/history/pm-github-webhook-inbox.jsonl b/.agents/pm/history/pm-github-webhook-inbox.jsonl new file mode 100644 index 00000000..2c884f63 --- /dev/null +++ b/.agents/pm/history/pm-github-webhook-inbox.jsonl @@ -0,0 +1,26 @@ +{"hash_algorithm":"sha256","ts":"2026-10-21T20:34:29.731Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"create","patch":[{"op":"add","path":"/metadata/id","value":"pm-github-webhook-inbox"},{"op":"add","path":"/metadata/title","value":"Reconcile authenticated GitHub webhooks through a durable deduplicating inbox"},{"op":"add","path":"/metadata/description","value":"Explicitly supplied issue webhook deliveries are signature verified before durable intake, deduplicated, and reconciled against current provider state into bounded proposals."},{"op":"add","path":"/metadata/type","value":"Feature"},{"op":"add","path":"/metadata/status","value":"open"},{"op":"add","path":"/metadata/priority","value":2},{"op":"add","path":"/metadata/tags","value":["changelog-unreleased","github","webhooks"]},{"op":"add","path":"/metadata/created_at","value":"2026-10-21T20:34:29.731Z"},{"op":"add","path":"/metadata/updated_at","value":"2026-10-21T20:34:29.731Z"},{"op":"add","path":"/metadata/author","value":"codex-agent"},{"op":"add","path":"/metadata/acceptance_criteria","value":"Duplicate and out-of-order events do not regress lifecycle; signature failures cause zero writes; polling refresh remains a recovery route; typed SDK and CLI with compact TOON/JSON refusals and remedies; real public-tracker copy validation without git; npm and Bun CI gates pass."}],"before_hash":"3cc22dff72be7b14824654a7a64ea62b04799939b2fee54c1b5f52ca60bf6df0","after_hash":"5957d190f659f67b0c552b5a502158449cc2f8784bf21e6795d29fc2e518710f","item_hash_version":3,"message":"","context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"5ecaccc89350ce109a598541367d5dffe380db51f469f91aa6da5fabe6396f81"} +{"hash_algorithm":"sha256","ts":"2026-10-21T20:34:31.473Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-21T20:34:31.473Z"},{"op":"add","path":"/metadata/assignee","value":"codex-agent"},{"op":"add","path":"/metadata/claim_principal","value":"codex-agent"}],"before_hash":"5957d190f659f67b0c552b5a502158449cc2f8784bf21e6795d29fc2e518710f","after_hash":"4ee08605cf768fe09d78d924308efe8e2bab1ef37f11cc25cd3edf0e2f539834","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"d9b79f0de9bb1be1fbfe1d5213c1feec46e4eb014cd1f8fcd64bd05eaf7e033a"} +{"hash_algorithm":"sha256","ts":"2026-10-21T20:34:34.085Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"update","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-21T20:34:34.085Z"},{"op":"replace","path":"/metadata/status","value":"in_progress"}],"before_hash":"4ee08605cf768fe09d78d924308efe8e2bab1ef37f11cc25cd3edf0e2f539834","after_hash":"a7a12babc98d4df41d9548ed1185b90b4045952456e2ec25c64df4a6a4b4e3c9","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"9eea01e8691de3d3485a8ab04ee24e450c1f4f79738273364b3974a9d8e832ae"} +{"hash_algorithm":"sha256","ts":"2026-10-21T20:34:36.573Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-21T20:34:36.573Z"},{"op":"add","path":"/metadata/comments","value":[{"created_at":"2026-10-21T20:34:36.573Z","author":"codex-agent","text":"Overlap audit: existing refresh.ts polls only explicitly linked issues and owns update-only import transactions. Installed CLI MCP tool definitions transport pm commands, with no authenticated GitHub event inbox. pm-observer src/ingest.ts and export-adapters.ts ingest agent exports, not provider webhooks. No duplicate feature found by tracker search. PR #164 owns historical tracker reconciliation; its items remain untouched. Design: authenticate exact payload bytes before all writes; store minimal durable delivery hints; reconcile via current-provider refresh previews only; retain receipts and retry failures. Polling refresh remains the apply and recovery route."}]}],"before_hash":"a7a12babc98d4df41d9548ed1185b90b4045952456e2ec25c64df4a6a4b4e3c9","after_hash":"7e32b0d1d6cd254fe5520027d24fddc21ad140e6eb495b27e599838ebb742e5c","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"5fa32f44bd007b6addb08e8518917835d1d802100889fbea016e437094c1c73a"} +{"hash_algorithm":"sha256","ts":"2026-10-21T20:41:00.447Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/1","value":{"created_at":"2026-10-21T20:41:00.447Z","author":"codex-agent","text":"Implementation: authenticated intake stores minimal immutable delivery hints; token-owned writer locking serializes concurrent deduplication; atomically published receipts are file/directory-flushed before acknowledgment. Reconciliation coalesces selected issue numbers and uses read-only current-provider refresh previews, persisting compact outcome receipts without item changes. No receiver is installed. Local targeted suite: 7/7 passing, new webhooks.ts measured 100% statements/branches/functions/lines. No-git acceptance removes git from PATH and sets a discovery ceiling. CI now invokes explicit npm and Bun release:check at unchanged thresholds; full gates run remotely because the host is saturated."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-21T20:41:00.447Z"}],"before_hash":"7e32b0d1d6cd254fe5520027d24fddc21ad140e6eb495b27e599838ebb742e5c","after_hash":"aa8871a0b000d098f95d1af04b48ffa692e99402a08f669e91f24b1c5d8c6050","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"912b8021103a7cc75d34e3977f9a9422d10a4b68f65f6069b9b1ea7b079620f9"} +{"hash_algorithm":"sha256","ts":"2026-10-21T20:41:05.228Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"files_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-21T20:41:05.228Z"},{"op":"add","path":"/metadata/files","value":[{"path":".github/workflows/ci.yml","scope":"project"},{"path":"index.ts","scope":"project"},{"path":"refresh.ts","scope":"project"},{"path":"test/webhooks.test.ts","scope":"project"},{"path":"webhooks.ts","scope":"project"}]}],"before_hash":"aa8871a0b000d098f95d1af04b48ffa692e99402a08f669e91f24b1c5d8c6050","after_hash":"afb8341e5ef12536e3c173ca10e9461e6cd548a995b02f89b92a5716fd58a806","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"01e92c3f34b83ffb52ca587b00fa0883842352c95293b4a2b7b8558b938b2484"} +{"hash_algorithm":"sha256","ts":"2026-10-21T20:41:12.952Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"docs_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-21T20:41:12.952Z"},{"op":"add","path":"/metadata/docs","value":[{"path":"docs/webhook-inbox.md","scope":"project"},{"path":"README.md","scope":"project"}]}],"before_hash":"afb8341e5ef12536e3c173ca10e9461e6cd548a995b02f89b92a5716fd58a806","after_hash":"3b13384c91fcedcd76fe640ca990f91a2afbeab9e414c59572fa6472ae38d08c","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"0e911374f8658873efe85f544e773553f7ef10afeb1c14b1720bfc83900f57c3"} +{"hash_algorithm":"sha256","ts":"2026-10-21T20:41:22.351Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-21T20:41:22.351Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"node --test test/webhooks.test.ts test/refresh.test.ts","scope":"project","provenance":{"author":"codex-agent","created_at":"2026-10-21T20:41:22.181Z","source_kind":"local_mutation","source_ref":"feat/webhook-reconciliation-inbox"}}]}],"before_hash":"3b13384c91fcedcd76fe640ca990f91a2afbeab9e414c59572fa6472ae38d08c","after_hash":"9a93f3015e0c776102da691201cf5f9268019aed514f03cc7c9af4e2514a2c4c","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"f27565a0a8dd88c8f6884d5ab49fc6cc7cc0b3b3f617205e092494271033d9f8"} +{"hash_algorithm":"sha256","ts":"2026-10-21T20:43:37.316Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/2","value":{"created_at":"2026-10-21T20:43:37.315Z","author":"codex-agent","text":"Behavioral revert proof: removing only signature verification from acceptGithubWebhook makes the signature-failures test exit 1 with Missing expected rejection; restoring it passes. Removing only the runRefresh number-scope predicate makes the stale-event test exit 1 because unrelated issue #2 is queried. Restored both functions. Real public-data acceptance: verified the source repository is public, copied main tracker into a non-git scratch project, made original files read-only, removed git from PATH, and hash-checked 294 original files. Measured 144 items, 5 linked issues, 10 accepted synthetic signed hints and 10 duplicates; 10 reconciled, 0 pending/retry, 5 proposals matching a fresh polling preview. All 294 original file hashes unchanged; compact JSON receipt 3274 bytes. No provider writes."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-21T20:43:37.316Z"}],"before_hash":"9a93f3015e0c776102da691201cf5f9268019aed514f03cc7c9af4e2514a2c4c","after_hash":"eaf13f65f7f7c67a8e24afdc4b437894bac724694cd2b59790d240f61544096f","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"cd8f4d5f79765f02d4371e26f34155102ff47f9aa1830ea7f6973e390e3a02f9"} +{"hash_algorithm":"sha256","ts":"2026-10-21T20:44:38.800Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_remove","patch":[{"op":"remove","path":"/metadata/tests"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-21T20:44:38.800Z"}],"before_hash":"eaf13f65f7f7c67a8e24afdc4b437894bac724694cd2b59790d240f61544096f","after_hash":"28a7b0331456c3fc9e22dbde4bac492c323f491925bc0962d52b702375ae6d02","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"d27db85b6b91dc0b9f120233b0a2071499dce016af1f778b70880a7f2613ffa1"} +{"hash_algorithm":"sha256","ts":"2026-10-21T20:44:41.014Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"tests_add","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-21T20:44:41.014Z"},{"op":"add","path":"/metadata/tests","value":[{"command":"node --test test/webhooks.test.ts","scope":"project","timeout_seconds":300,"provenance":{"author":"codex-agent","created_at":"2026-10-21T20:44:40.939Z","source_kind":"local_mutation","source_ref":"feat/webhook-reconciliation-inbox"}}]}],"before_hash":"28a7b0331456c3fc9e22dbde4bac492c323f491925bc0962d52b702375ae6d02","after_hash":"ce28741aa4952f41efc47939667c07553066bc1c29724b5592f613362e8fe834","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"79d7c5bd3c0256c44b62871c99c3b5e0413cc476455922624f79f0b6f9d09004"} +{"hash_algorithm":"sha256","ts":"2026-10-21T20:44:43.174Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/3","value":{"created_at":"2026-10-21T20:44:43.174Z","author":"codex-agent","text":"The broader local refresh-plus-webhook linked run reached its default 120-second limit under host load after 16 passing refresh scenarios; no assertion failure was reported before termination. Narrowed the linked local acceptance to the seven webhook scenarios (including provider-state scoping and polling apply/recovery), with an explicit 300-second ceiling. Full existing-suite evidence is delegated to CI as requested."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-21T20:44:43.174Z"}],"before_hash":"ce28741aa4952f41efc47939667c07553066bc1c29724b5592f613362e8fe834","after_hash":"8019e608b05cffdfe4b08aeacb45fdd96a2d04a6ad23ba084b0ae97e89d4f120","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"4455fff98ab65505145eeb5d9139f19e00995aff5f8f4aaa7772a0f2a55dbedf"} +{"hash_algorithm":"sha256","ts":"2026-10-21T20:45:12.778Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/4","value":{"created_at":"2026-10-21T20:45:12.776Z","author":"codex-agent","text":"Local acceptance complete: pm test --run --progress passes the linked webhook command, 7 tests, zero failures/skips/TODOs; typecheck, test typecheck, lint, docstring, zero-duplication and changelog checks pass. New inbox module has exact 100/100/100/100 targeted coverage. Public-data validation and behavioral fault checks are recorded above. Implementation is handed to CI for unchanged-threshold npm/Bun release gates; no merge or release is authorized. Operational limits: local POSIX filesystem inbox, explicit adapter intake, indefinite deduplication retention, proposals must be refreshed before apply."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-21T20:45:12.778Z"}],"before_hash":"8019e608b05cffdfe4b08aeacb45fdd96a2d04a6ad23ba084b0ae97e89d4f120","after_hash":"03cfee59aa86055a05919f0c3696a69d3a20dd81a46bf43ec1f8bfaf4c2122ca","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"d747ad513e69cee20c0190f5f8fe711f9a0d56490330f02d634af5e8b1b5a39c"} +{"hash_algorithm":"sha256","ts":"2026-10-21T20:45:17.157Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-21T20:45:17.157Z"}],"before_hash":"03cfee59aa86055a05919f0c3696a69d3a20dd81a46bf43ec1f8bfaf4c2122ca","after_hash":"a03ae706b668388d4c77962e28355b63dd72255e93e699aa606ed19d38b0a5b3","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"c08f7c1d37dd881a303439ab469c9db202852fdd7b407249fddbd255fa6ad8ba"} +{"hash_algorithm":"sha256","ts":"2026-10-21T21:08:57.943Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-21T21:08:57.943Z"},{"op":"add","path":"/metadata/assignee","value":"codex-agent"},{"op":"add","path":"/metadata/claim_principal","value":"codex-agent"}],"before_hash":"a03ae706b668388d4c77962e28355b63dd72255e93e699aa606ed19d38b0a5b3","after_hash":"c77cf510b4ff01d2f169957eb8ef4c09c843060f0af73e181ed0f777c9a9bcc3","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"943e87bfc291d1b083413fe1d31e5eddba8498e796391f9c8f3c7ac263238763"} +{"hash_algorithm":"sha256","ts":"2026-10-21T21:10:01.817Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/5","value":{"created_at":"2026-10-21T21:10:01.817Z","author":"codex-agent","text":"Final concurrency review found that the common inbox lock spans provider reads, so slow GitHub access can block authenticated intake until its bounded lock wait expires. Separate the intake and reconciliation lock lanes: immutable atomically published hints permit concurrent intake while processors remain serialized. Add a real HTTP delayed-read regression and behavioral revert proof, then rerun CI on the new head."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-21T21:10:01.817Z"}],"before_hash":"c77cf510b4ff01d2f169957eb8ef4c09c843060f0af73e181ed0f777c9a9bcc3","after_hash":"d9d3aa9e359b2a811fed80c1b67b602bbded002069a9a0943bf71c7970f0577e","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"39d39ebd5aae5c7481a3ee9b72aa0fc1ecff622b6b65a00255ce10ae75706a32"} +{"hash_algorithm":"sha256","ts":"2026-10-21T21:10:54.627Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/6","value":{"created_at":"2026-10-21T21:10:54.627Z","author":"codex-agent","text":"Concurrency regression: withhold the real HTTP provider response, then accept another signed delivery before releasing that response. The separate lock lanes pass; reverting only reconciliation to the shared intake lock exits 1 with inbox_unavailable after contention. Restored the fix. CI npm/Bun release gates now run in parallel jobs, preserving Node 22/26, native Bun acceptance and all existing thresholds."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-21T21:10:54.627Z"}],"before_hash":"d9d3aa9e359b2a811fed80c1b67b602bbded002069a9a0943bf71c7970f0577e","after_hash":"e2da0acca1c2ee526c3c2fc8dba353cd5cc88b4f13f8f9b2ed127735fe792036","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"e6cee6088af21a53dcdaeb3f09cd6f31b531598f81619486258f1677a4d3ba6a"} +{"hash_algorithm":"sha256","ts":"2026-10-21T21:14:01.708Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/7","value":{"created_at":"2026-10-21T21:14:01.708Z","author":"codex-agent","text":"Final local result after concurrency hardening: linked pm test passes 9/9, zero failures/skips/TODOs; targeted inbox coverage remains exact 100/100/100/100. Typecheck, test typecheck and lint pass. Separate processor/intake locks keep receipt acceptance available during delayed provider reads. Refresh previews exclude durable inbox storage; reverting only that exclusion makes the opaque-storage regression fail with provider_unavailable. Repeated public-data validation on this code: 144 items, 294/294 original file hashes unchanged, 5 issues, 10 accepted hints, 10 duplicates, 10 reconciled, zero pending/retry, 5 proposals matching polling and a 3274-byte report. The previous CI run was canceled after both npm gates passed because it predates this fix; only the new head will supply final gate evidence."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-21T21:14:01.708Z"}],"before_hash":"e2da0acca1c2ee526c3c2fc8dba353cd5cc88b4f13f8f9b2ed127735fe792036","after_hash":"d567a8d173130fef45b57e5aa0b2abe9da46149e71839fa1f2878f5b2291926a","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"5cc4a1a430536455efe6ec5ba5572295f60fcdf9ca283e8c70a742fd8db5d3c3"} +{"hash_algorithm":"sha256","ts":"2026-10-21T21:14:04.296Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"39835e27742ddcc80332e430","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-21T21:14:04.296Z"}],"before_hash":"d567a8d173130fef45b57e5aa0b2abe9da46149e71839fa1f2878f5b2291926a","after_hash":"734e8435b98f471e06b3817081c004540655f4efc20db78832757e41bd31d851","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"10916fee1048cce9dce4ccd792f82f8ab079604b2d42935a36c2eb935e9f65a3"} +{"hash_algorithm":"sha256","ts":"2026-10-22T11:12:28.931Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"09e026e98c925283fb8033b0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"claim","patch":[{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T11:12:28.931Z"},{"op":"add","path":"/metadata/assignee","value":"codex-agent"},{"op":"add","path":"/metadata/claim_principal","value":"codex-agent"}],"before_hash":"734e8435b98f471e06b3817081c004540655f4efc20db78832757e41bd31d851","after_hash":"d2709e5f3c62044fb40976aec0076ee05c5195d4895f2374745b25731927f236","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"9a9ea7ffd0064c75b98c12f16a7da2066e4edd15d40354bcb25d273eda2490da"} +{"hash_algorithm":"sha256","ts":"2026-10-22T11:12:36.254Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"09e026e98c925283fb8033b0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/8","value":{"created_at":"2026-10-22T11:12:36.254Z","author":"codex-agent","text":"PR #165 review round: fetched all two unresolved threads with complete comments and the latest CodeRabbit review; no outside-diff findings. Merged current main before fixes. Verified age-only lock reclamation and retry-prefix starvation against the pinned SDK and implementation. Plan: require explicit recovery for expired inbox locks so live workers cannot be displaced; persist a rotating batch cursor before provider reads, including failed batches; exercise real non-git trackers and delayed HTTP reads, then record behavioral reverts and unchanged npm/Bun release gates."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T11:12:36.254Z"}],"before_hash":"d2709e5f3c62044fb40976aec0076ee05c5195d4895f2374745b25731927f236","after_hash":"5d051280584d999bc5ef35f3a89a3994f0c70be7034e4ff40695a43e5ab048e3","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"4b1e893b937c11086c27ae2e5d806f1545abad5ede654dabaa6ae914a1e30aa0"} +{"hash_algorithm":"sha256","ts":"2026-10-22T11:15:04.909Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"09e026e98c925283fb8033b0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/9","value":{"created_at":"2026-10-22T11:15:04.907Z","author":"codex-agent","text":"Verified both findings and self-reviewed the full PR diff. Applied the pinned SDK forceRequiredForStaleLock guard to both processor and intake locks; it prevents age-only displacement rather than implementing a heartbeat. Documented explicit crash-lock recovery. Added an atomic repository-scoped rotating cursor, advanced before provider I/O so thrown requests also yield to later hints. One directory snapshot keeps queue counts consistent during concurrent intake. Regression fixtures cover missing states, thrown HTTP failures, wrapping, corrupt cursor refusal, aged live processor contention and aged intake locks. Clean-home strict health passes; normal-home strict health detects unrelated globally installed host/extension version skew. No merge receipts are pending."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T11:15:04.909Z"}],"before_hash":"5d051280584d999bc5ef35f3a89a3994f0c70be7034e4ff40695a43e5ab048e3","after_hash":"60e5db31fc86876915cc02691b1d7b4aabb12689d6caa422b12f1e76a6addbe7","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"ff608cefec174d30c02eae10ed847d98624eb13ce7932817b6811eaf112372ab"} +{"hash_algorithm":"sha256","ts":"2026-10-22T11:15:18.387Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"09e026e98c925283fb8033b0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/10","value":{"created_at":"2026-10-22T11:15:18.387Z","author":"codex-agent","text":"Behavioral revert proofs (all restored): reverting only locked acquisition to forceRequiredForStaleLock=false makes node --test --test-name-pattern=\"expired live processor|expired intake\" test/webhooks.test.ts fail 2/2 with Missing expected rejection; a competing processor and intake can steal aged live locks. Reverting only batch selection in reconcileGithubWebhooks to selected.slice(0, limit) makes node --test --test-name-pattern=\"retry batches\" test/webhooks.test.ts fail 1/1: the later ready issue reconciles 0 instead of 1. Moving only cursor persistence past the provider read fails the same regression 1/1, proving thrown batches must advance. Fixed cursor regression passes 1/1. Next: complete Node and Bun gates, unchanged four 100% coverage dimensions."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T11:15:18.387Z"}],"before_hash":"60e5db31fc86876915cc02691b1d7b4aabb12689d6caa422b12f1e76a6addbe7","after_hash":"90fb5d1ed757e04fcf75f1538db365b58b211fdb94c711a6e53877aab1408b6e","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"a3869715b878b3fd60302f3691a70ffb769397afae6e018dd04d1dfc676cf5b8"} +{"hash_algorithm":"sha256","ts":"2026-10-22T11:27:36.761Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"09e026e98c925283fb8033b0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/11","value":{"created_at":"2026-10-22T11:27:36.760Z","author":"codex-agent","text":"Current fixed-code acceptance: pm test pm-github-webhook-inbox --run --progress passes 12/12, zero failures/skips/cancellations/TODOs; bun test --timeout 600000 test/webhooks.test.ts passes 12/12 on Bun 1.3.5. Isolated targeted c8 reports webhooks.ts at exact 100/100/100/100. npm ci, build, test typecheck, lint, docstrings and zero-duplication pass. Clean-home full npm release gate passed privacy and is still executing existing integration scenarios under host contention without failures. Push the completed review fixes to obtain complete clean CI npm/Bun gate evidence; no merge or release. Thread PRRT_kwDOTbN4pM6vALGP fixes age-only processor lock theft, including the same intake defect. Thread PRRT_kwDOTbN4pM6vALGU fixes retry starvation, including thrown provider requests."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T11:27:36.761Z"}],"before_hash":"90fb5d1ed757e04fcf75f1538db365b58b211fdb94c711a6e53877aab1408b6e","after_hash":"989b49d5f5b08faf1308c7867bb6eb9105c3c35ca571f85b561ff8a6619f8540","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"6c1f53b2c551894fceff20f2899bbb8ae412a323adf238cafde540fd59e61845"} +{"hash_algorithm":"sha256","ts":"2026-10-22T11:47:27.555Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"09e026e98c925283fb8033b0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":null,"topic":null},"op":"comment_add","patch":[{"op":"add","path":"/metadata/comments/12","value":{"created_at":"2026-10-22T11:47:27.554Z","author":"codex-agent","text":"Release-gate receipt for code commit 3e0dbe4a3d079f166275ee44413d7c3b3c04f35a: CI run https://github.com/unbraind/pm-github/actions/runs/39770780741 reports all four required release steps successful: npm run release:check and bun run release:check on Node 22 and 26. Downloaded completed Bun job logs independently confirm 1001/1001 tests on each Node version, zero failures/skips/cancellations/TODOs, and exact 100/100/100/100 across 17 authored modules at unchanged thresholds. Both npm jobs are performing additional native Bun acceptance after their successful complete release gates; their full logs are not yet downloadable. The redundant local clean-home full npm gate was stopped after the four CI release gates passed, with no assertion failures reported before termination; it is not counted as a passing local gate. Local linked PM acceptance and native Bun webhook acceptance both pass 12/12, and targeted inbox coverage is exact 100/100/100/100. Full PR self-review and repeated complete thread fetch found no further findings. Operational risk: crashed inbox workers now require explicitly verified stale-lock recovery; receipts must be retained. Handoff remains in_progress for orchestrator verification; no threads replied to or resolved, no merge and no release."}},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T11:47:27.555Z"}],"before_hash":"989b49d5f5b08faf1308c7867bb6eb9105c3c35ca571f85b561ff8a6619f8540","after_hash":"f5b4e380b62087ab9d2f2437c1f4a3d96b1a3acfa0711ea4d1796a99a77ee9ae","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"substantive","record_hash_version":1,"record_hash":"48d12d47737dcb5b7de8b56ea620347201907822d43ee30e797edeb193ce4b25"} +{"hash_algorithm":"sha256","ts":"2026-10-22T11:47:29.084Z","author":"codex-agent","author_source":"asserted","agent_harness":"codex","agent_model":"gpt-6.1-sol","agent_model_source":"probe","agent_instance":"09e026e98c925283fb8033b0","agent_provenance":{"model":{"value":"gpt-6.1-sol","source":"probe"},"effort":{"value":"high","source":"probe"},"role":{"value":"implementer","source":"argv"},"topic":null},"op":"release","patch":[{"op":"remove","path":"/metadata/claim_principal"},{"op":"remove","path":"/metadata/assignee"},{"op":"replace","path":"/metadata/updated_at","value":"2026-10-22T11:47:29.084Z"}],"before_hash":"f5b4e380b62087ab9d2f2437c1f4a3d96b1a3acfa0711ea4d1796a99a77ee9ae","after_hash":"df17f3b015e10a53c9689fe93c1eb8368ef1fd6a07fa30da0138544b68c40688","item_hash_version":3,"context":{"agent_provenance_outcomes":{"model":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"effort":{"status":"resolved","resolver":"codex_session_file","rule_version":"v1"},"version":{"status":"unavailable","reason":"harness_unavailable","resolver":"ai_agent_version","rule_version":"v1"}}},"event_class":"maintenance","record_hash_version":1,"record_hash":"14a338e46a0f660be569b19014214d13ef6d80ef481bf7cc7635eea07768da2d"} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b464dc6..e15b1f66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,44 +61,41 @@ jobs: # still exiting successfully. ./node_modules/.bin/pm health --strict-exit --require-merge-drivers - - name: Type check - run: npm run typecheck + - name: Complete npm release gate + run: npm run release:check - - name: Build - run: npm run build - - - name: Verify complete docstring coverage - run: npm run docstring - - - name: ESLint source policy - run: npm run lint - - - name: Complete source duplication gate - run: npm run duplication - - # Fail-closed identity / secret / host-path audit over the local object - # store (pm-github-zqad). In CI this is evidence about refs and tags; the - # local release:check run additionally covers unreachable objects. - - name: Privacy gate (identities, secrets, host paths) - run: npm run privacy - - - name: Test with coverage gate - run: npm run coverage + - name: Verify Bun can install the package graph + run: bun install --no-save - - name: Production dependency audit - run: npm run audit:prod + - name: Verify native Bun extension behavior + run: npm run test:bun - - name: Verify npm package contents - run: npm run pack:dry-run + bun-release: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [22, 26] + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true - - name: Verify generated changelog - run: npm run changelog:check + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node-version }} + cache: npm - - name: Verify every publish invocation is attested - run: npm run verify:release-publish-attestation + - name: Setup Bun + uses: oven-sh/setup-bun@v2.2.0 + with: + bun-version: "1.3.14" - - name: Verify Bun can install the package graph - run: bun install --no-save + - name: Install dependencies + run: npm ci - - name: Verify native Bun extension behavior - run: npm run test:bun + - name: Complete Bun release gate + run: bun run release:check diff --git a/.gitignore b/.gitignore index 44593b9f..e84e4575 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ coverage/ # one turns the health gate red; the journal has no value once the transaction # has landed. Deliberately outside any `pm-cli:` fence, which the CLI rewrites. .agents/pm/transactions/ + +# Durable webhook inbox receipts are deployment data; back up separately. +.agents/pm/github-webhooks/ diff --git a/CHANGELOG.md b/CHANGELOG.md index caca411f..227ffe08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Reconcile authenticated GitHub webhooks through a durable deduplicating inbox ([pm-github-webhook-inbox](https://github.com/unbraind/pm-github/blob/main/.agents/pm/features/pm-github-webhook-inbox.toon)) - Let reusable workflows carry their own release version ([pm-github-znig](https://github.com/unbraind/pm-github/blob/main/.agents/pm/features/pm-github-znig.toon)) - Preserve imported GitHub lifetimes, closure evidence and release attribution while linking cross-provider items ([pm-github-c093](https://github.com/unbraind/pm-github/blob/main/.agents/pm/features/pm-github-c093.toon)) - Refresh only already-linked GitHub items without importing new ones ([pm-github-ullj](https://github.com/unbraind/pm-github/blob/main/.agents/pm/features/pm-github-ullj.toon)) diff --git a/README.md b/README.md index 446a2f12..ff80c1db 100644 --- a/README.md +++ b/README.md @@ -143,10 +143,31 @@ only the tracker; commit it with its history and regenerate the caller's changelog in a closeout PR. The [automated closeout workflow](#automated-closeout-workflow) performs that daily review-PR preparation for fleet repositories. +## Authenticated webhook reconciliation + +`pm github webhook receive --payload delivery.json --delivery +--event issues --signature ` accepts explicitly supplied deliveries. +Set `PM_GITHUB_WEBHOOK_SECRET` through your secret manager. Exact payload bytes +are authenticated before any write; a durable inbox deduplicates delivery IDs. + +`pm github webhook reconcile --limit 20` reads current GitHub state +for already-linked issues and returns compact proposals with lifecycle deltas. +It never applies the event's lifecycle or changes items. Out-of-order events +therefore cannot reopen a closed item. Output defaults to TOON; add `--json` +for JSON. Signature and storage refusals include typed codes and remedies. + +Use `pm github refresh --dry-run` to review full changes and +`--atomic` to apply a fresh polling read. Polling remains the recovery route +for missed deliveries or unavailable webhooks. Back up the ignored durable +`github-webhooks` directory inside your tracker. See the +[CLI and typed SDK example](docs/webhook-inbox.md) for deployment, crash recovery, +retention and supported-event details. + ## Without git With an explicit repository, GitHub import, export, refresh, sync, project commands, -blocker checks and closeout work in a plain pm tracker without a git executable. +blocker checks, closeout and webhook receive/reconcile work in a plain pm tracker +without a git executable. They still require their normal GitHub access and credentials. Privacy scanning with `github gate --diff ` also works there. Reading a proposed tracker diff with `github gate` requires a git repository; otherwise pass an explicit diff. diff --git a/docs/webhook-inbox.md b/docs/webhook-inbox.md new file mode 100644 index 00000000..d907a6d4 --- /dev/null +++ b/docs/webhook-inbox.md @@ -0,0 +1,101 @@ +# Webhook inbox example + +Receive an `issues` delivery from an adapter that preserves the exact request +body bytes and original headers. Configure a high-entropy webhook secret through +`PM_GITHUB_WEBHOOK_SECRET` using your deployment's secret manager. The secret is +separate from the GitHub API token used for reconciliation. The adapter must +supply an explicit expected repository and respond successfully only after +intake returns `ok: true`. + +```sh +pm github webhook receive example/project --payload delivery.json \ + --delivery "$DELIVERY_ID" --event "$EVENT_TYPE" --signature "$SIGNATURE" --json +pm github webhook reconcile example/project --limit 20 --json +# Review a fresh full diff before applying through the existing polling route: +pm github refresh example/project --dry-run +pm github refresh example/project --atomic +``` + +JSON uses the host's `--json`; its default output is compact TOON. No receiver, +network listener or scheduled worker is installed. Your adapter supplies the +payload file and delivery headers; it owns HTTP transport, body size limits, +rate limits and secret rotation. Signature verification uses HMAC-SHA256 over +the original bytes and a constant-time comparison, before parsing or writing. +The secret must match the webhook configuration. See GitHub's +[signature verification](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries) +and [delivery guidance](https://docs.github.com/en/webhooks/using-webhooks/best-practices-for-using-webhooks). +GitHub signs the body; the adapter must preserve the associated headers. + +```ts +import { + acceptGithubWebhook, reconcileGithubWebhooks, WebhookRefusal, + type GithubWebhookDelivery, +} from 'pm-github'; + +/** Intake receives the exact original body, not JSON.stringify(parsedBody). */ +async function receive(pmRoot: string, event: GithubWebhookDelivery) { + try { + return await acceptGithubWebhook(pmRoot, event); + } catch (error) { + if (error instanceof WebhookRefusal) { + return { ok: false, code: error.code, remedy: error.remedy }; + } + throw error; + } +} + +const report = await reconcileGithubWebhooks(pmRoot, 'example/project', 20); +// report.proposals contains item IDs, field names and lifecycle deltas. +// No item is created or changed by receive/reconcile. +``` + +The durable inbox lives under the tracker's `github-webhooks` directory, with +repository and delivery names hashed. Include it in deployment backups; it is +ignored by git and is not a disposable runtime cache. Intake stores only the +schema version, repository, delivery ID, payload SHA256 and issue number. +It never persists raw payloads, signatures or secrets. The same delivery and +bytes are a duplicate even after reconciliation. Reusing an ID with different +bytes refuses with `delivery_conflict`. Delivery IDs are repository scoped. +Receipts are retained indefinitely for deduplication; deleting them permits +replay and should be an explicit operator retention decision. + +Intake writers share a token-owned tracker lock. Reconciliation uses a separate +processor lock so a slow provider read never blocks intake. It reads immutable, +atomically published hints; deliveries arriving during a batch remain for the +next reconciliation. Counts describe the selected snapshot. Intake acknowledges after atomic +publication and file/directory flushes on a local POSIX filesystem. The lock +never reclaims an existing writer lock based on age alone. If a worker crashes, +inspect its lock and confirm the worker has stopped before explicitly recovering +the stale lock through the host. Partial atomic-write temporary files are ignored. +Retrying after lock recovery is safe. This filesystem inbox requires an +initialized local tracker path; remote clients should invoke it at the owning +filesystem service rather than invent a local path for a remote tracker. + +Reconciliation defaults to 20 deliveries, capped at 100, coalesces issue numbers, +and queries only already-linked owners. A `closed` event followed by an older +`reopened` event still proposes the current provider state. Actual provider +reopening can produce an open proposal. Events never directly set statuses. +Unknown owners are acknowledged as `unlinked`, without importing anything; +later linking is recovered by polling. Missing/inaccessible provider state and +provider failures retain pending deliveries. An unfinished import refuses +preview until polling refresh recovers it. A durable repository-scoped cursor +rotates through pending deliveries, wrapping at the end. It advances before +provider reads, including failed batches, so retrying hints cannot starve later +deliveries. Retries remain pending and are revisited on subsequent passes. + +Each successful hint gets an atomic `.done.json` receipt containing a timestamp +and its compact proposal. A crash after receipt publication but before response +can lose the caller's response, so inspect that receipt or run a fresh polling +preview. Proposals are observations, not reusable apply tokens; always re-read +provider state before applying. The default report bounds rows and excludes +issue bodies, titles and full tag lists. Its `remaining`, `retry` and `unlinked` +counts make incomplete work explicit. Persistent receipts contain only compact +lifecycle changes, not the full provider projection. + +Typed refusal codes include `invalid_signature`, `invalid_event`, +`invalid_options`, `delivery_conflict`, `inbox_corrupt`, `inbox_unavailable` and +`provider_unavailable`. Every refusal includes a remedy and exits nonzero in +the CLI. Invalid signatures perform zero inbox or item writes, including when +the inbox does not exist. Unsupported event types, pull request payloads and +repository mismatches also refuse. Polling `github refresh` continues to work +independently of webhook configuration or delivery availability. diff --git a/index.ts b/index.ts index d2b35a98..597b7bca 100644 --- a/index.ts +++ b/index.ts @@ -29,6 +29,9 @@ import { type ImportFidelity, } from "./import-fidelity.ts"; import { refreshChanges, runRefresh } from "./refresh.ts"; +import { acceptGithubWebhook, reconcileGithubWebhooks, WebhookRefusal } from "./webhooks.ts"; +export { acceptGithubWebhook, reconcileGithubWebhooks, WebhookRefusal } from "./webhooks.ts"; +export type { GithubWebhookDelivery, WebhookProposal, WebhookReconciliation, WebhookRefusalCode } from "./webhooks.ts"; import http from "node:http"; import https from "node:https"; import crypto from "node:crypto"; @@ -6925,6 +6928,40 @@ export default defineExtension({ }, }); + api.registerCommand({ + name: "github webhook receive", + description: "Verify and durably deduplicate an explicitly supplied issues webhook. Reads PM_GITHUB_WEBHOOK_SECRET; never stores payloads or changes items.", + arguments: [{ name: "owner/repo", required: true, description: "Expected repository boundary" }], + flags: [ + { long: "--payload", value_name: "file", description: "File containing the exact original request body bytes" }, + { long: "--delivery", value_name: "id", description: "Original X-GitHub-Delivery header" }, + { long: "--event", value_name: "type", description: "Original X-GitHub-Event header (issues)" }, + { long: "--signature", value_name: "sha256=...", description: "Original X-Hub-Signature-256 header" }, + ], + async run(ctx: CommandHandlerContext) { + const { payload, delivery, event, signature } = ctx.options; + if (!ctx.args[0] || typeof payload !== "string" || typeof delivery !== "string" || typeof event !== "string" || typeof signature !== "string") { + throw new WebhookRefusal("invalid_options", "Supply owner/repo and --payload, --delivery, --event, --signature; set PM_GITHUB_WEBHOOK_SECRET securely."); + } + let bytes: Buffer; + try { + if (fs.statSync(payload).size > 25 * 1024 * 1024) throw new Error("oversized"); + bytes = fs.readFileSync(payload); + } catch { throw new WebhookRefusal("invalid_event", "Supply a readable original payload file of at most 25 MiB."); } + return acceptGithubWebhook(ctx.pm_root, { repo: ctx.args[0], delivery, event, signature, + payload: bytes, secret: process.env.PM_GITHUB_WEBHOOK_SECRET ?? "" }); + }, + }); + api.registerCommand({ + name: "github webhook reconcile", + description: "Reconcile pending deliveries against current GitHub state into compact proposals, without changing items. Poll with github refresh for apply or recovery.", + arguments: [{ name: "owner/repo", required: true, description: "Expected repository boundary" }], + flags: [{ long: "--limit", value_name: "N", description: "Maximum deliveries per batch, 1 to 100 (default 20)" }], + async run(ctx: CommandHandlerContext) { + return reconcileGithubWebhooks(ctx.pm_root, ctx.args[0] ?? "", ctx.options.limit === undefined ? 20 : Number(ctx.options.limit)); + }, + }); + // command — legacy `pm gh-issues import ` (delegates to core) // ----------------------------------------------------------------------- api.registerCommand({ diff --git a/refresh.ts b/refresh.ts index 436e3dd3..a4bf2368 100644 --- a/refresh.ts +++ b/refresh.ts @@ -125,7 +125,7 @@ async function readRefreshItems(pmRoot: string): Promise { const scratch = await mkdtemp(join(tmpdir(), "pm-github-refresh-read-")); try { const root = join(scratch, "pm"); - await cp(pmRoot, root, { recursive: true, filter: source => !["runtime", "locks"].includes(basename(source)) }); + await cp(pmRoot, root, { recursive: true, filter: source => !["runtime", "locks", "github-webhooks"].includes(basename(source)) }); return decodeCompletePmItems(certifyCompleteListResult(await listAllComplete({ includeBody: true }, { pmRoot: root, cwd: root, noExtensions: true }))); } finally { await rm(scratch, { recursive: true, force: true }); @@ -133,13 +133,13 @@ async function readRefreshItems(pmRoot: string): Promise { } /** Build a zero-create refresh plan, then commit changed existing targets with import transactions. */ -export async function runRefresh(repoArg: string | undefined, pmRoot: string, options: Record) { +export async function runRefresh(repoArg: string | undefined, pmRoot: string, options: Record, scope?: { numbers: readonly number[] }) { if (!repoArg || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repoArg)) throw new CommandError("Usage: pm github refresh [--dry-run] [--since ] [--limit N] [--atomic]", EXIT_CODE.USAGE); const repo = repoArg.toLowerCase(); const opts = parseImportOptions(options); /** Selected targets bounded by the caller's --limit, in ascending order. */ const bounded = (candidates: readonly RefreshTarget[]): RefreshTarget[] => - opts.limit === undefined ? [...candidates] : candidates.slice(0, opts.limit); + (opts.limit === undefined ? [...candidates] : candidates.slice(0, opts.limit)).filter(target => scope === undefined || scope.numbers.includes(target.number)); let items = await readRefreshItems(pmRoot); let all = selectRefreshTargets(items, repo); let targets = bounded(all); diff --git a/test/webhooks.test.ts b/test/webhooks.test.ts new file mode 100644 index 00000000..48cb57fb --- /dev/null +++ b/test/webhooks.test.ts @@ -0,0 +1,338 @@ +/** Real no-git trackers and HTTP transport prove authenticated durable reconciliation. */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash, createHmac } from "node:crypto"; +import type { ServerResponse } from "node:http"; +import { mkdtemp, rm, readdir, readFile, writeFile, mkdir, cp, symlink, open, chmod } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { tmpdir } from "node:os"; +import { init, create, get, acquireLock } from "@unbrained/pm-cli/sdk"; +import { createExtensionTestHarness } from "@unbrained/pm-cli/sdk/testing"; +import extension, { acceptGithubWebhook, reconcileGithubWebhooks, WebhookRefusal, type GithubWebhookDelivery } from "../index.ts"; +import { runRefresh } from "../refresh.ts"; +import { withEnv, withMockGithub, jsonResponse } from "./helpers/mock-github-server.ts"; +import { boundedCli } from "./helpers/bounded-cli.ts"; + +const packageRoot = join(import.meta.dirname, ".."); +/** Initialize real storage outside git, then remove git from PATH for the entire scenario. */ +async function fixture(fn: (root: string, directory: string) => Promise) { + const directory = await mkdtemp(join(tmpdir(), "pm-webhook-")); + const root = join(directory, ".agents/pm"); + try { + await init("fixture", { defaults: true, withPackages: false, agentGuidance: "skip" }, { cwd: directory, pmRoot: root }); + await withEnv({ PM_PATH: root, PM_AUTHOR: "fixture", GITHUB_TOKEN: "fixture", GH_TOKEN: undefined, + PATH: directory, GIT_CEILING_DIRECTORIES: dirname(directory) }, () => fn(root, directory)); + } finally { await rm(directory, { recursive: true, force: true }); } +} +/** Sign exact bytes with a synthetic test-only secret. */ +function delivery(id = "delivery-1", number = 1, action = "opened", value?: unknown): GithubWebhookDelivery { + const payload = Buffer.from(value === undefined ? JSON.stringify({ action, repository: { full_name: "fixture/repo" }, issue: { number, state: "open", title: "Sensitive payload content" } }) : String(value)); + const secret = "fixture-webhook-key"; + return { repo: "fixture/repo", delivery: id, event: "issues", secret, payload, + signature: "sha256=" + createHmac("sha256", secret).update(payload).digest("hex") }; +} +/** Byte snapshots include all files, not only items. */ +async function snapshot(root: string): Promise> { + const entries = await readdir(root, { recursive: true, withFileTypes: true }); + return Object.fromEntries(await Promise.all(entries.filter(entry => entry.isFile()).map(async entry => { + const file = join(entry.parentPath, entry.name); + return [file.slice(root.length), (await readFile(file)).toString("hex")]; + }))); +} +/** Assert a structured refusal rather than matching incidental native error messages. */ +async function refused(operation: () => Promise, code: WebhookRefusal["code"]) { + await assert.rejects(operation, error => { + assert.ok(error instanceof WebhookRefusal); + assert.equal(error.code, code); + assert.ok(error.remedy.length > 20); + assert.deepEqual(JSON.parse(error.message), { ok: false, code, remedy: error.remedy }); + return true; + }); +} +/** Simulate provider state at the HTTP boundary; issue lifecycle is independent of event payloads. */ +function upstream(state = "CLOSED") { + return { number: 1, title: "Current provider title", body: "Provider body", state, stateReason: state === "CLOSED" ? "COMPLETED" : "REOPENED", + createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-02-03T00:00:00Z", closedAt: state === "CLOSED" ? "2026-02-02T00:00:00Z" : null, + url: "https://github.com/fixture/repo/issues/1", author: { login: "fixture" }, + labels: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } }; +} + +test("signature failures perform zero writes even on absent storage and duplicate deliveries", async () => fixture(async root => { + const input = delivery(); + for (const patch of [{ secret: "" }, { signature: "sha1=wrong" }, { signature: "sha256=" + "0".repeat(64) }, { payload: Buffer.from("changed bytes") }]) { + const before = await snapshot(root); + await refused(() => acceptGithubWebhook(root, { ...input, ...patch }), "invalid_signature"); + assert.deepEqual(await snapshot(root), before); + } + await acceptGithubWebhook(root, input); + const before = await snapshot(root); + await refused(() => acceptGithubWebhook(root, { ...input, signature: "bad" }), "invalid_signature"); + assert.deepEqual(await snapshot(root), before); + await refused(() => acceptGithubWebhook(join(root, "absent"), { ...input, secret: "" }), "invalid_signature"); + assert.deepEqual(await snapshot(root), before); +})); + +test("durable concurrent deduplication, repository scope and conflicting delivery bytes", async () => fixture(async root => { + const input = delivery(); + const results = await Promise.all(Array.from({ length: 4 }, () => acceptGithubWebhook(root, input))); + assert.equal(results.filter(result => !result.duplicate).length, 1); + const before = await snapshot(root); + assert.equal((await acceptGithubWebhook(root, { ...input, repo: "FIXTURE/REPO" })).duplicate, true); + assert.deepEqual(await snapshot(root), before); + await refused(() => acceptGithubWebhook(root, delivery("delivery-1", 2)), "delivery_conflict"); + assert.deepEqual(await snapshot(root), before); + const receipt = Object.entries(before).find(([path]) => path.includes("github-webhooks") && path.endsWith(".json"))!; + const value = Buffer.from(receipt[1], "hex").toString(); + for (const forbidden of [input.secret, input.signature, "Sensitive payload content", "action"]) assert.ok(!value.includes(forbidden)); + assert.equal(Object.keys(JSON.parse(value)).length, 5); +})); + +test("signed unsupported, malformed and cross-repository payloads are refused without writes", async () => fixture(async root => { + const before = await snapshot(root); + const base = delivery(); + for (const input of [ + { ...base, repo: "bad" }, { ...base, event: "issue_comment" }, { ...base, delivery: "../../bad" }, + delivery("a", 0), delivery("a", 1.5), delivery("a", 1, ""), delivery("a", 1, "opened", "not-json"), + delivery("a", 1, "opened", "[]"), delivery("a", 1, "opened", "null"), + delivery("a", 1, "opened", JSON.stringify({ action: "opened", repository: { full_name: "other/repo" }, issue: { number: 1 } })), + delivery("a", 1, "opened", JSON.stringify({ action: "opened", repository: { full_name: "fixture/repo" }, issue: { number: 1, pull_request: {} } })), + ]) await refused(() => acceptGithubWebhook(root, input), input.repo === "bad" ? "invalid_options" : "invalid_event"); + assert.deepEqual(await snapshot(root), before); + const large = delivery("large", 1, "opened", " ".repeat(25 * 1024 * 1024 + 1)); + await refused(() => acceptGithubWebhook(root, large), "invalid_event"); +})); + +test("old reopened events cannot regress lifecycle; proposals read current state and polling recovers", async () => fixture(async root => { + const local = (await create({ title: "Local stale title", tags: "gh:fixture/repo#1" }, { cwd: root, pmRoot: root })).item; + await create({ title: "Unrelated linked issue", tags: "gh:fixture/repo#2" }, { cwd: root, pmRoot: root }); + for (const [id, action] of [["new", "closed"], ["old", "reopened"], ["missing-link", "opened"]]) await acceptGithubWebhook(root, delivery(id, id === "missing-link" ? 99 : 1, action)); + let state = "CLOSED"; + await withMockGithub((request, response, body) => { + if (request.url?.includes("/timeline")) { jsonResponse(response, 200, []); return; } + const query = (JSON.parse(body) as { query: string }).query; + if (query.includes("timelineItems")) { jsonResponse(response, 200, { data: { repository: { issue: { timelineItems: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } } } } } }); return; } + jsonResponse(response, 200, { data: { repository: { i1: upstream(state), i2: null } } }); + }, async server => { + const before = await snapshot(root); + const receipt = await reconcileGithubWebhooks(root, "fixture/repo"); + assert.equal(receipt.selected, 3); + assert.equal(receipt.reconciled, 3); + assert.equal(receipt.unlinked, 1); + assert.equal(receipt.proposals.length, 1); + assert.deepEqual(receipt.proposals[0]!.lifecycle.find(change => change.field === "status"), { field: "status", old: "open", new: "closed" }); + const after = await snapshot(root); + for (const [file, bytes] of Object.entries(before)) assert.equal(after[file], bytes, file); + assert.equal((await get(local.id, {}, { pmRoot: root, cwd: root })).item.status, "open"); + assert.ok(server.requests.filter(request => !request.body.includes("timelineItems") && request.url === "/graphql").every(request => !request.body.includes("i2:") && !request.body.includes("i99:"))); + const empty = await reconcileGithubWebhooks(root, "fixture/repo", 1); + assert.equal(empty.selected, 0); + assert.equal(empty.proposals.length, 0); + await runRefresh("fixture/repo", root, { atomic: true }, { numbers: [1] }); + assert.equal((await get(local.id, {}, { pmRoot: root, cwd: root })).item.status, "closed"); + await acceptGithubWebhook(root, delivery("older-again", 1, "reopened")); + assert.equal((await reconcileGithubWebhooks(root, "fixture/repo")).proposals.length, 0); + state = "OPEN"; + await acceptGithubWebhook(root, delivery("real-reopen", 1, "closed")); + const reopened = await reconcileGithubWebhooks(root, "fixture/repo"); + assert.ok(reopened.proposals[0]!.lifecycle.some(change => change.field === "status" && change.new === "open")); + }); +})); + +test("missing provider state stays pending, limits drain batches, and API failures retry", async () => fixture(async root => { + await create({ title: "Local", tags: "gh:fixture/repo#1" }, { cwd: root, pmRoot: root }); + await acceptGithubWebhook(root, delivery("first")); + await acceptGithubWebhook(root, delivery("second")); + await withMockGithub((_request, response) => jsonResponse(response, 200, { data: { repository: { i1: null } } }), async () => { + const report = await reconcileGithubWebhooks(root, "fixture/repo", 1); + assert.equal(report.retry, 1); + assert.equal(report.remaining, 2); + assert.equal(report.reconciled, 0); + }); + const before = await snapshot(root); + await withMockGithub((_request, response) => jsonResponse(response, 401, { message: "unauthorized" }), async () => { + await refused(() => reconcileGithubWebhooks(root, "fixture/repo"), "provider_unavailable"); + }); + const after = await snapshot(root); + for (const [file, bytes] of Object.entries(before)) { + if (!file.endsWith("/cursor.json")) assert.equal(after[file], bytes, file); + } + for (const limit of [0, -1, 101, NaN, 1.5]) await refused(() => reconcileGithubWebhooks(root, "fixture/repo", limit), "invalid_options"); + await withMockGithub((_request, response) => jsonResponse(response, 200, { data: { repository: { i1: upstream("OPEN") } } }), async () => { + assert.equal((await reconcileGithubWebhooks(root, "fixture/repo", 1)).remaining, 1); + assert.equal((await reconcileGithubWebhooks(root, "fixture/repo", 1)).remaining, 0); + }); +})); + +test("retry batches rotate durably, wrap, and advance even when provider reads throw", async () => fixture(async root => { + const ids = ["retry-a", "retry-b", "ready-c"].sort((a, b) => + createHash("sha256").update(a).digest("hex").localeCompare(createHash("sha256").update(b).digest("hex"))); + for (let index = 0; index < ids.length; index++) { + await create({ title: "Linked", tags: `gh:fixture/repo#${index + 1}` }, { cwd: root, pmRoot: root }); + await acceptGithubWebhook(root, delivery(ids[index]!, index + 1)); + } + let unavailable = false; + const seen: number[] = []; + await withMockGithub((_request, response, body) => { + const query = (JSON.parse(body) as { query: string }).query; + const numbers = [...query.matchAll(/i(\d+): issue/g)].map(match => Number(match[1])); + seen.push(...numbers); + if (unavailable) { jsonResponse(response, 401, { message: "unauthorized" }); return; } + jsonResponse(response, 200, { data: { repository: Object.fromEntries(numbers.map(number => + [`i${number}`, number === 3 ? { ...upstream("OPEN"), number: 3, url: "https://github.com/fixture/repo/issues/3" } : null])) } }); + }, async () => { + assert.equal((await reconcileGithubWebhooks(root, "fixture/repo", 1)).retry, 1); + unavailable = true; + await refused(() => reconcileGithubWebhooks(root, "fixture/repo", 1), "provider_unavailable"); + unavailable = false; + const ready = await reconcileGithubWebhooks(root, "fixture/repo", 1); + assert.equal(ready.reconciled, 1); + assert.equal(ready.remaining, 2); + assert.equal(ready.proposals[0]!.number, 3); + assert.equal((await reconcileGithubWebhooks(root, "fixture/repo", 1)).retry, 1); + assert.deepEqual(seen, [1, 2, 3, 1]); + // Wrap within a batch too; the acknowledged delivery stays excluded. + assert.equal((await reconcileGithubWebhooks(root, "fixture/repo", 2)).retry, 2); + assert.deepEqual(seen.slice(-2), [1, 2]); + }); +})); + +test("expired live processor locks cannot be stolen during delayed provider reads", async () => fixture(async root => { + await create({ title: "Linked", tags: "gh:fixture/repo#1" }, { cwd: root, pmRoot: root }); + await acceptGithubWebhook(root, delivery("before-read")); + let signalRead: (response: ServerResponse) => void; + const reading = new Promise(resolve => { signalRead = resolve; }); + let reads = 0; + await withMockGithub((_request, response) => { + reads++; + if (reads === 1) signalRead(response); + else jsonResponse(response, 200, { data: { repository: { i1: upstream("OPEN") } } }); + }, async () => { + const reconciling = reconcileGithubWebhooks(root, "fixture/repo"); + const response = await reading; + try { + const file = join(root, "locks/github-webhooks-reconcile.lock"); + const lock = JSON.parse(await readFile(file, "utf8")) as Record; + await writeFile(file, JSON.stringify({ ...lock, created_at: "2000-01-01T00:00:00.000Z" })); + await refused(() => reconcileGithubWebhooks(root, "fixture/repo"), "inbox_unavailable"); + assert.equal(reads, 1); + assert.equal((JSON.parse(await readFile(file, "utf8")) as Record).token, lock.token); + assert.equal((await acceptGithubWebhook(root, delivery("during-read"))).duplicate, false); + } finally { + jsonResponse(response, 200, { data: { repository: { i1: upstream("OPEN") } } }); + await reconciling; + } + }); + const release = await acquireLock(root, "github-webhooks-reconcile", 300, "fixture"); + await release(); +})); + +test("expired intake locks also require explicit recovery", async () => fixture(async root => { + const release = await acquireLock(root, "github-webhooks", 300, "fixture"); + try { + const file = join(root, "locks/github-webhooks.lock"); + const lock = JSON.parse(await readFile(file, "utf8")) as Record; + await writeFile(file, JSON.stringify({ ...lock, created_at: "2000-01-01T00:00:00.000Z" })); + await refused(() => acceptGithubWebhook(root, delivery()), "inbox_unavailable"); + assert.equal((JSON.parse(await readFile(file, "utf8")) as Record).token, lock.token); + } finally { await release(); } + assert.equal((await acceptGithubWebhook(root, delivery())).duplicate, false); +})); + +test("slow provider reconciliation never blocks durable authenticated intake", async () => fixture(async root => { + await create({ title: "Linked", tags: "gh:fixture/repo#1" }, { cwd: root, pmRoot: root }); + await acceptGithubWebhook(root, delivery("before-read")); + let signalRead: (response: ServerResponse) => void; + const reading = new Promise(resolve => { signalRead = resolve; }); + let delayed = false; + await withMockGithub((_request, response, body) => { + if (!delayed && (JSON.parse(body) as { query: string }).query.includes("i1: issue")) { + delayed = true; signalRead(response); return; + } + jsonResponse(response, 200, { data: { repository: { i1: upstream("OPEN") } } }); + }, async () => { + const reconciling = reconcileGithubWebhooks(root, "fixture/repo"); + const response = await reading; + try { + // This must finish while the provider response is still withheld. Sharing + // the reconciliation lock makes it refuse after the five-second wait. + assert.equal((await acceptGithubWebhook(root, delivery("during-read"))).duplicate, false); + } finally { + jsonResponse(response, 200, { data: { repository: { i1: upstream("OPEN") } } }); + await reconciling; + } + assert.equal((await reconcileGithubWebhooks(root, "fixture/repo")).selected, 1); + }); +})); + +test("provider previews do not copy durable inbox storage", async () => fixture(async root => { + await create({ title: "Linked", tags: "gh:fixture/repo#1" }, { cwd: root, pmRoot: root }); + await acceptGithubWebhook(root, delivery()); + const opaque = join(root, "github-webhooks", "opaque-receipt"); + await writeFile(opaque, "Adapter-owned storage must never enter tracker previews"); + await chmod(opaque, 0); + try { + await withMockGithub((_request, response) => jsonResponse(response, 200, { data: { repository: { i1: upstream("OPEN") } } }), async () => { + assert.equal((await reconcileGithubWebhooks(root, "fixture/repo")).reconciled, 1); + }); + } finally { await chmod(opaque, 0o600); } +})); + +test("corrupt receipts fail closed and native storage failures become typed refusals", async () => fixture(async root => { + await refused(() => acceptGithubWebhook(join(root, "absent"), delivery()), "inbox_unavailable"); + await acceptGithubWebhook(root, delivery()); + const directory = join(root, "github-webhooks", createHash("sha256").update("fixture/repo").digest("hex")); + const file = join(directory, (await readdir(directory)).find(file => file.endsWith(".json"))!); + const original = await readFile(file, "utf8"); + for (const value of ["broken", "{}", original.replace('"version":1', '"version":2'), original.replace('"delivery":"delivery-1"', '"delivery":"other"'), original.replace('"number":1', '"number":0')]) { + await writeFile(file, value); + await refused(() => reconcileGithubWebhooks(root, "fixture/repo"), "inbox_corrupt"); + await refused(() => acceptGithubWebhook(root, delivery()), "inbox_corrupt"); + } + await writeFile(file, original); + const cursor = join(directory, "cursor.json"); + for (const value of ["broken", "null", "{}", '{"after":"../../bad"}']) { + await writeFile(cursor, value); + await refused(() => reconcileGithubWebhooks(root, "fixture/repo"), "inbox_corrupt"); + } + await rm(cursor); + await rm(join(root, "github-webhooks"), { recursive: true }); + await writeFile(join(root, "github-webhooks"), "unavailable"); + await refused(() => acceptGithubWebhook(root, delivery()), "inbox_unavailable"); +})); + +test("registered CLI contracts validate arguments and render compact JSON and TOON without git", async () => fixture(async (root, directory) => { + const harness = await createExtensionTestHarness(extension, { capabilities: ["commands", "importers", "schema", "hooks", "preflight", "search"] }); + harness.assertCommandContract({ name: "github webhook receive", flags: ["--payload", "--delivery", "--event", "--signature"] }); + harness.assertCommandContract({ name: "github webhook reconcile", flags: ["--limit"] }); + await refused(() => harness.runCommand({ command: "github webhook receive", pmRoot: root }), "invalid_options"); + await refused(() => harness.runCommand({ command: "github webhook reconcile", pmRoot: root }), "invalid_options"); + const input = delivery(); + const payload = join(directory, "payload.json"); + await writeFile(payload, input.payload); + const options = { payload, delivery: input.delivery, event: input.event, signature: input.signature }; + await refused(() => harness.runCommand({ command: "github webhook receive", args: [input.repo], pmRoot: root, options: { ...options, payload: directory } }), "invalid_event"); + const oversized = join(directory, "oversized"); + const handle = await open(oversized, "w"); + await handle.truncate(25 * 1024 * 1024 + 1); await handle.close(); + await refused(() => harness.runCommand({ command: "github webhook receive", args: [input.repo], pmRoot: root, options: { ...options, payload: oversized } }), "invalid_event"); + await withEnv({ PM_GITHUB_WEBHOOK_SECRET: undefined }, async () => { + await refused(() => harness.runCommand({ command: "github webhook receive", args: [input.repo], pmRoot: root, options }), "invalid_signature"); + }); + await withEnv({ PM_GITHUB_WEBHOOK_SECRET: input.secret }, async () => { + await harness.runCommand({ command: "github webhook receive", args: [input.repo], pmRoot: root, options }); + }); + await harness.runCommand({ command: "github webhook reconcile", args: [input.repo], pmRoot: root, options: { limit: "1" } }); + const installed = join(root, "extensions/pm-github"); + await mkdir(installed, { recursive: true }); + await cp(join(packageRoot, "dist"), join(installed, "dist"), { recursive: true }); + for (const file of ["manifest.json", "package.json"]) await cp(join(packageRoot, file), join(installed, file)); + await symlink(join(packageRoot, "node_modules"), join(installed, "node_modules"), "dir"); + const cli = join(packageRoot, "node_modules/@unbrained/pm-cli/dist/cli.js"); + for (const format of ["json", "toon"]) { + const result = await boundedCli(process.execPath, [cli, "--path", root, ...(format === "json" ? ["--json"] : []), "github", "webhook", "reconcile", input.repo], directory, process.env); + assert.equal(result.code, 0, result.stderr); + assert.ok(result.stdout.length < 1500, result.stdout); + if (format === "json") assert.equal(JSON.parse(result.stdout).selected, 0); + else assert.match(result.stdout, /selected: 0/); + } +})); diff --git a/webhooks.ts b/webhooks.ts new file mode 100644 index 00000000..cb30dd27 --- /dev/null +++ b/webhooks.ts @@ -0,0 +1,194 @@ +/** Durable authenticated issue-event hints; provider reads, never event payloads, produce proposals. */ +import { createHash, createHmac, timingSafeEqual } from "node:crypto"; +import { open, mkdir, readdir, readFile } from "node:fs/promises"; +import { join, basename } from "node:path"; +import { acquireLock, writeFileAtomic } from "@unbrained/pm-cli/sdk"; +import { runRefresh } from "./refresh.ts"; + +/** Stable refusal discriminators; no secret, payload or storage path is included in diagnostics. */ +export type WebhookRefusalCode = "invalid_signature" | "invalid_event" | "invalid_options" | "delivery_conflict" | "inbox_corrupt" | "inbox_unavailable" | "provider_unavailable"; +/** A machine-readable refusal with an actionable, public-safe remedy. */ +export class WebhookRefusal extends Error { + /** Recognized by the pm command host as a handled failure, preventing handler replay. */ + readonly exitCode = 1; + /** Stable discriminator shared by SDK and CLI diagnostics. */ + readonly code: WebhookRefusalCode; + /** Public-safe instructions for recovering from this refusal. */ + readonly remedy: string; + /** Construct a typed refusal with a compact JSON diagnostic for CLI callers. */ + constructor(code: WebhookRefusalCode, remedy: string) { + super(JSON.stringify({ ok: false, code, remedy })); + this.name = "WebhookRefusal"; + this.code = code; + this.remedy = remedy; + } +} +/** Exact bytes and delivery headers supplied by the receiving adapter; secret stays in memory. */ +export interface GithubWebhookDelivery { + repo: string; + delivery: string; + event: string; + signature: string; + secret: string; + payload: Uint8Array; +} +/** Minimal durable identity; raw payloads and credentials are never persisted. */ +interface InboxRecord { + version: 1; + delivery: string; + digest: string; + repo: string; + number: number; +} +/** Compact proposed updates; fetch full values with polling refresh --dry-run before applying. */ +export interface WebhookProposal { + id: string; + number: number; + fields: string[]; + lifecycle: Array<{ field: string; old: unknown; new: unknown }>; +} +/** A bounded reconciliation receipt, also persisted for operator inspection. */ +export interface WebhookReconciliation { + ok: true; + repo: string; + selected: number; + remaining: number; + reconciled: number; + retry: number; + unlinked: number; + proposals: WebhookProposal[]; + recovery: string; +} + +/** Normalize an explicit repository boundary before opening storage. */ +function repository(value: string): string { + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value)) throw new WebhookRefusal("invalid_options", "Supply an explicit owner/repo."); + return value.toLowerCase(); +} +/** Narrow untrusted JSON without assuming the provider schema. */ +function object(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} +/** Hash public identities into fixed-length storage names. */ +function hash(value: string | Uint8Array): string { + return createHash("sha256").update(value).digest("hex"); +} +/** Preserve typed refusals while hiding native errors that may include paths or payloads. */ +function refusal(error: unknown, code: WebhookRefusalCode, remedy: string): never { + if (error instanceof WebhookRefusal) throw error; + throw new WebhookRefusal(code, remedy); +} +/** Flush published records and the containing directory before acknowledging durable intake. */ +async function persist(file: string, value: unknown): Promise { + await writeFileAtomic(file, JSON.stringify(value) + "\n"); + await flush(file); +} +/** Finish a prior publisher's flush as well as a fresh receipt's durable acknowledgment. */ +async function flush(file: string): Promise { + for (const target of [file, join(file, ".."), join(file, "../.."), join(file, "../../..")]) { + const handle = await open(target, "r"); + try { await handle.sync(); } finally { await handle.close(); } + } +} +/** Serialize independent writers using the host's token-owned, crash-recoverable tracker lock. */ +async function locked(pmRoot: string, repo: string, operation: (directory: string) => Promise, lockId = "github-webhooks"): Promise { + try { + await readFile(join(pmRoot, "settings.json"), "utf8"); + // Age alone cannot prove a worker is dead while it awaits provider or disk I/O. + const release = await acquireLock(pmRoot, lockId, 300, "pm-github", false, true, 5000); + try { + const parent = join(pmRoot, "github-webhooks"); + await mkdir(parent, { recursive: true, mode: 0o700 }); + const directory = join(parent, hash(repo)); + await mkdir(directory, { recursive: true, mode: 0o700 }); + return await operation(directory); + } finally { await release(); } + } catch (error) { + refusal(error, "inbox_unavailable", "Restore initialized writable local tracker storage; inspect stale locks and recover only after confirming their worker has stopped, then retry; retain github-webhooks receipts."); + } +} +/** Authenticate exact bytes before parsing, locking, initializing storage or performing any write. */ +export async function acceptGithubWebhook(pmRoot: string, input: GithubWebhookDelivery): Promise<{ ok: true; repo: string; delivery: string; duplicate: boolean }> { + if (!input.secret || !/^sha256=[a-f0-9]{64}$/.test(input.signature) || + !timingSafeEqual(createHmac("sha256", input.secret).update(input.payload).digest(), Buffer.from(input.signature.slice(7), "hex"))) { + throw new WebhookRefusal("invalid_signature", "Supply the original unmodified payload bytes, X-Hub-Signature-256 and the matching webhook secret."); + } + const repo = repository(input.repo); + if (input.event !== "issues" || !/^[A-Za-z0-9-]{1,128}$/.test(input.delivery) || input.payload.byteLength > 25 * 1024 * 1024) { + throw new WebhookRefusal("invalid_event", "Supply an issues event, its original X-GitHub-Delivery identifier and a payload of at most 25 MiB."); + } + let payload: Record; + try { payload = object(JSON.parse(Buffer.from(input.payload).toString("utf8"))); } + catch { throw new WebhookRefusal("invalid_event", "Supply the signed GitHub JSON payload without re-encoding it."); } + const issue = object(payload.issue); + const source = object(payload.repository); + if (typeof payload.action !== "string" || !payload.action.trim() || typeof source.full_name !== "string" || source.full_name.toLowerCase() !== repo || + !Number.isSafeInteger(issue.number) || Number(issue.number) < 1 || "pull_request" in issue) { + throw new WebhookRefusal("invalid_event", "Supply an issue event from the explicitly selected repository; pull requests and ambiguous identities are unsupported."); + } + const record: InboxRecord = { version: 1, repo, delivery: input.delivery, digest: hash(input.payload), number: Number(issue.number) }; + return locked(pmRoot, repo, async directory => { + const file = join(directory, `${hash(record.delivery)}.json`); + if ((await readdir(directory)).includes(`${hash(record.delivery)}.json`)) { + const existing = await readRecord(file, repo); + if (JSON.stringify(existing) !== JSON.stringify(record)) throw new WebhookRefusal("delivery_conflict", "The delivery identifier already names different bytes; retain both source receipts and investigate the adapter."); + // A previous process may have died after rename but before its flush. + await flush(file); + return { ok: true, repo, delivery: record.delivery, duplicate: true }; + } + await persist(file, record); + return { ok: true, repo, delivery: record.delivery, duplicate: false }; + }); +} +/** Refuse malformed durable hints before fetching or acknowledging any selected delivery. */ +async function readRecord(file: string, repo: string): Promise { + try { + const value = object(JSON.parse(await readFile(file, "utf8"))); + if (value.version !== 1 || value.repo !== repo || typeof value.delivery !== "string" || !/^[A-Za-z0-9-]{1,128}$/.test(value.delivery) || + typeof value.digest !== "string" || !/^[a-f0-9]{64}$/.test(value.digest) || !Number.isSafeInteger(value.number) || Number(value.number) < 1 || + basename(file) !== `${hash(value.delivery)}.json`) throw new Error("invalid record"); + return { version: 1, repo, delivery: value.delivery, digest: value.digest, number: Number(value.number) }; + } catch { throw new WebhookRefusal("inbox_corrupt", "Restore the affected delivery receipt from backup or redelivery; do not delete the inbox. Poll with github refresh meanwhile."); } +} +/** Read current provider state once per linked issue, persist compact proposals, and leave failed hints retryable. */ +export async function reconcileGithubWebhooks(pmRoot: string, repoArg: string, limit = 20): Promise { + const repo = repository(repoArg); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new WebhookRefusal("invalid_options", "Use a delivery limit from 1 to 100 (default 20)."); + return locked(pmRoot, repo, async directory => { + const names = new Set(await readdir(directory)); + const files = [...names].filter(file => /^[a-f0-9]{64}\.json$/.test(file)).sort(); + const selected = files.filter(file => !names.has(file.replace(".json", ".done.json"))); + let after = ""; + if (names.has("cursor.json")) { + try { + const cursor = object(JSON.parse(await readFile(join(directory, "cursor.json"), "utf8"))); + if (typeof cursor.after !== "string" || !/^[a-f0-9]{64}\.json$/.test(cursor.after)) throw new Error("invalid cursor"); + after = cursor.after; + } catch { throw new WebhookRefusal("inbox_corrupt", "Restore the reconciliation cursor from backup; retain delivery receipts and poll with github refresh meanwhile."); } + } + const batch = [...selected.filter(file => file > after), ...selected.filter(file => file <= after)].slice(0, limit); + const records = await Promise.all(batch.map(file => readRecord(join(directory, file), repo))); + // Advance even when a provider request throws, so retries cannot starve later hints. + if (batch.length) await persist(join(directory, "cursor.json"), { after: batch[batch.length - 1] }); + let preview: Awaited> | undefined; + try { preview = records.length ? await runRefresh(repo, pmRoot, { "dry-run": true }, { numbers: records.map(record => record.number) }) : undefined; } + catch (error) { refusal(error, "provider_unavailable", "Retry reconciliation after restoring GitHub access or completing unfinished imports with github refresh; pending deliveries are retained."); } + const proposals: WebhookProposal[] = []; + for (const row of preview?.plan ?? []) { + if (row.changes.length) proposals.push({ id: row.id, number: row.number, fields: row.changes.map(change => change.field), + lifecycle: row.changes.filter(change => ["status", "github_state", "github_updated_at", "closed_at", "completed_at"].includes(change.field)) }); + } + let reconciled = 0; + let unlinked = 0; + for (const record of records) { + const row = preview!.plan.find(entry => entry.number === record.number); + if (row?.reason) continue; + if (!row) unlinked++; + await persist(join(directory, `${hash(record.delivery)}.done.json`), { delivery: record.delivery, reconciledAt: new Date().toISOString(), + outcome: row ? "proposed" : "unlinked", proposals: proposals.filter(proposal => proposal.number === record.number) }); + reconciled++; + } + return { ok: true, repo, selected: records.length, remaining: selected.length - reconciled, reconciled, retry: records.length - reconciled, unlinked, + proposals, recovery: `pm github refresh ${repo} --dry-run` }; + }, "github-webhooks-reconcile"); +}