approval-md 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -914
- package/SPEC.md +101 -13
- package/cli.js +134 -7
- package/dist/src/adapters/smtp.d.ts +18 -0
- package/dist/src/adapters/smtp.js +24 -3
- package/dist/src/adapters/smtp.js.map +1 -1
- package/dist/src/channels/contract.d.ts +72 -0
- package/dist/src/channels/contract.js +161 -1
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/relay-server.d.ts +90 -0
- package/dist/src/channels/relay-server.js +295 -0
- package/dist/src/channels/relay-server.js.map +1 -0
- package/dist/src/channels/relay.d.ts +273 -0
- package/dist/src/channels/relay.js +564 -0
- package/dist/src/channels/relay.js.map +1 -0
- package/dist/src/channels/tagging.d.ts +25 -0
- package/dist/src/channels/tagging.js +63 -6
- package/dist/src/channels/tagging.js.map +1 -1
- package/dist/src/channels/telegram-minimal.d.ts +251 -0
- package/dist/src/channels/telegram-minimal.js +618 -0
- package/dist/src/channels/telegram-minimal.js.map +1 -0
- package/dist/src/channels/telegram-webhook.d.ts +273 -0
- package/dist/src/channels/telegram-webhook.js +536 -0
- package/dist/src/channels/telegram-webhook.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +725 -20
- package/dist/src/channels/telegram.js +1332 -123
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/cli/amend.js +358 -5
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.js +43 -6
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/audit-card.d.ts +1 -1
- package/dist/src/cli/audit-card.js +40 -1
- package/dist/src/cli/audit-card.js.map +1 -1
- package/dist/src/cli/audit.d.ts +7 -5
- package/dist/src/cli/audit.js +22 -10
- package/dist/src/cli/audit.js.map +1 -1
- package/dist/src/cli/channel-relay.d.ts +108 -0
- package/dist/src/cli/channel-relay.js +334 -0
- package/dist/src/cli/channel-relay.js.map +1 -0
- package/dist/src/cli/channel-telegram-webhook.d.ts +275 -0
- package/dist/src/cli/channel-telegram-webhook.js +716 -0
- package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
- package/dist/src/cli/channel-telegram.d.ts +347 -28
- package/dist/src/cli/channel-telegram.js +648 -56
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +1 -1
- package/dist/src/cli/channel.js +5 -1
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/daemon.js +37 -5
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.d.ts +63 -0
- package/dist/src/cli/doctor.js +856 -111
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.d.ts +29 -1
- package/dist/src/cli/execute.js +387 -21
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/exit-codes.d.ts +12 -0
- package/dist/src/cli/exit-codes.js +13 -0
- package/dist/src/cli/exit-codes.js.map +1 -1
- package/dist/src/cli/gate.d.ts +2 -0
- package/dist/src/cli/gate.js +233 -2
- package/dist/src/cli/gate.js.map +1 -1
- package/dist/src/cli/help.d.ts +10 -1
- package/dist/src/cli/help.js +283 -54
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook.d.ts +485 -6
- package/dist/src/cli/hook.js +2370 -70
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/init.js +16 -7
- package/dist/src/cli/init.js.map +1 -1
- package/dist/src/cli/instructions.js +24 -3
- package/dist/src/cli/instructions.js.map +1 -1
- package/dist/src/cli/log-anchor.d.ts +55 -0
- package/dist/src/cli/log-anchor.js +199 -60
- package/dist/src/cli/log-anchor.js.map +1 -1
- package/dist/src/cli/log-unlock.d.ts +23 -0
- package/dist/src/cli/log-unlock.js +109 -0
- package/dist/src/cli/log-unlock.js.map +1 -0
- package/dist/src/cli/main.d.ts +0 -12
- package/dist/src/cli/main.js +72 -4
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/muse.d.ts +2 -0
- package/dist/src/cli/muse.js +62 -0
- package/dist/src/cli/muse.js.map +1 -0
- package/dist/src/cli/payload.js +79 -1
- package/dist/src/cli/payload.js.map +1 -1
- package/dist/src/cli/scaffold.js +6 -0
- package/dist/src/cli/scaffold.js.map +1 -1
- package/dist/src/cli/serve.d.ts +53 -0
- package/dist/src/cli/serve.js +270 -0
- package/dist/src/cli/serve.js.map +1 -0
- package/dist/src/cli/up.js +26 -2
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/verb-registry.d.ts +2 -0
- package/dist/src/cli/verb-registry.js +338 -39
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/core/attest-bootstrap.d.ts +95 -0
- package/dist/src/core/attest-bootstrap.js +135 -0
- package/dist/src/core/attest-bootstrap.js.map +1 -0
- package/dist/src/core/attest.d.ts +20 -3
- package/dist/src/core/attest.js +25 -0
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +153 -12
- package/dist/src/core/audit.js +415 -20
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-lease.d.ts +322 -0
- package/dist/src/core/channel-lease.js +663 -0
- package/dist/src/core/channel-lease.js.map +1 -0
- package/dist/src/core/command-class.d.ts +51 -0
- package/dist/src/core/command-class.js +766 -8
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/daemon-host.d.ts +178 -0
- package/dist/src/core/daemon-host.js +228 -0
- package/dist/src/core/daemon-host.js.map +1 -0
- package/dist/src/core/daemon-identity.d.ts +238 -0
- package/dist/src/core/daemon-identity.js +252 -0
- package/dist/src/core/daemon-identity.js.map +1 -0
- package/dist/src/core/gate.d.ts +364 -6
- package/dist/src/core/gate.js +802 -27
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/harness-version.d.ts +128 -4
- package/dist/src/core/harness-version.js +179 -12
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/harness-wait.d.ts +82 -3
- package/dist/src/core/harness-wait.js +89 -3
- package/dist/src/core/harness-wait.js.map +1 -1
- package/dist/src/core/log-lock.d.ts +435 -0
- package/dist/src/core/log-lock.js +1093 -0
- package/dist/src/core/log-lock.js.map +1 -0
- package/dist/src/core/log-subscribe.d.ts +21 -0
- package/dist/src/core/log-subscribe.js +16 -6
- package/dist/src/core/log-subscribe.js.map +1 -1
- package/dist/src/core/log-write-layer.d.ts +55 -0
- package/dist/src/core/log-write-layer.js +91 -0
- package/dist/src/core/log-write-layer.js.map +1 -0
- package/dist/src/core/log.d.ts +130 -22
- package/dist/src/core/log.js +473 -50
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/payload-census.d.ts +51 -0
- package/dist/src/core/payload-census.js +125 -2
- package/dist/src/core/payload-census.js.map +1 -1
- package/dist/src/core/payload-store.d.ts +31 -0
- package/dist/src/core/payload-store.js +113 -8
- package/dist/src/core/payload-store.js.map +1 -1
- package/dist/src/core/payload.d.ts +18 -3
- package/dist/src/core/payload.js +20 -4
- package/dist/src/core/payload.js.map +1 -1
- package/dist/src/core/policy-diff.d.ts +10 -0
- package/dist/src/core/policy-diff.js +10 -2
- package/dist/src/core/policy-diff.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +7 -1
- package/dist/src/core/policy-explain.js +16 -1
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +24 -0
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +38 -0
- package/dist/src/core/policy-match.js +35 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +40 -0
- package/dist/src/core/policy-proposal.js +37 -16
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/prompt-layout.d.ts +186 -1
- package/dist/src/core/prompt-layout.js +345 -3
- package/dist/src/core/prompt-layout.js.map +1 -1
- package/dist/src/core/read-scope.d.ts +10 -0
- package/dist/src/core/read-scope.js +4 -0
- package/dist/src/core/read-scope.js.map +1 -1
- package/dist/src/core/run-payload.d.ts +160 -0
- package/dist/src/core/run-payload.js +304 -0
- package/dist/src/core/run-payload.js.map +1 -0
- package/dist/src/core/sender-identity.d.ts +59 -11
- package/dist/src/core/sender-identity.js +79 -11
- package/dist/src/core/sender-identity.js.map +1 -1
- package/dist/src/core/state.d.ts +52 -1
- package/dist/src/core/state.js +52 -6
- package/dist/src/core/state.js.map +1 -1
- package/dist/src/core/telegram-config.d.ts +79 -0
- package/dist/src/core/telegram-config.js +108 -0
- package/dist/src/core/telegram-config.js.map +1 -1
- package/dist/src/core/validate.d.ts +20 -6
- package/dist/src/core/validate.js +0 -0
- package/dist/src/core/validate.js.map +1 -1
- package/dist/src/core/verify.d.ts +23 -1
- package/dist/src/core/verify.js +20 -2
- package/dist/src/core/verify.js.map +1 -1
- package/dist/src/daemon/audit.d.ts +80 -5
- package/dist/src/daemon/audit.js +126 -6
- package/dist/src/daemon/audit.js.map +1 -1
- package/dist/src/daemon/daemon.d.ts +174 -2
- package/dist/src/daemon/daemon.js +298 -7
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/daemon/projection.d.ts +13 -2
- package/dist/src/daemon/projection.js +39 -6
- package/dist/src/daemon/projection.js.map +1 -1
- package/dist/src/mcp/server.d.ts +39 -3
- package/dist/src/mcp/server.js +22 -3
- package/dist/src/mcp/server.js.map +1 -1
- package/dist/src/muse/server.d.ts +16 -0
- package/dist/src/muse/server.js +238 -0
- package/dist/src/muse/server.js.map +1 -0
- package/dist/src/serve/archive.d.ts +166 -0
- package/dist/src/serve/archive.js +363 -0
- package/dist/src/serve/archive.js.map +1 -0
- package/dist/src/serve/arguments.d.ts +93 -0
- package/dist/src/serve/arguments.js +228 -0
- package/dist/src/serve/arguments.js.map +1 -0
- package/dist/src/serve/credentials.d.ts +91 -0
- package/dist/src/serve/credentials.js +143 -0
- package/dist/src/serve/credentials.js.map +1 -0
- package/dist/src/serve/follow.d.ts +86 -0
- package/dist/src/serve/follow.js +101 -0
- package/dist/src/serve/follow.js.map +1 -0
- package/dist/src/serve/hook-thread.d.ts +134 -0
- package/dist/src/serve/hook-thread.js +333 -0
- package/dist/src/serve/hook-thread.js.map +1 -0
- package/dist/src/serve/hook-worker.d.ts +134 -0
- package/dist/src/serve/hook-worker.js +173 -0
- package/dist/src/serve/hook-worker.js.map +1 -0
- package/dist/src/serve/server.d.ts +412 -0
- package/dist/src/serve/server.js +1251 -0
- package/dist/src/serve/server.js.map +1 -0
- package/docs/README-extended.md +768 -0
- package/docs/cli-reference.md +2139 -67
- package/package.json +4 -2
- package/schema/event.schema.json +413 -30
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
- package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
- package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
- package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
- package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
- package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
- package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
- package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
- package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
- package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
- package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
- package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
- package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
- package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
- package/schema/fixtures/event/valid/audit-sampled.json +2 -1
- package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
- package/schema/fixtures/event/valid/daemon-identity.json +16 -0
- package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
- package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
- package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
- package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
- package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
- package/schema/policy.schema.json +102 -6
|
@@ -110,15 +110,17 @@
|
|
|
110
110
|
import { readFileSync, statSync } from "node:fs";
|
|
111
111
|
import { isAbsolute, resolve as resolvePathSegments } from "node:path";
|
|
112
112
|
import { HUMAN_ACTOR_ENV, resolveHumanActor } from "../core/attest.js";
|
|
113
|
+
import { isProposalTask } from "../core/gate.js";
|
|
113
114
|
import { assembleBatch } from "../channels/batch.js";
|
|
114
|
-
import { recordChannelDecision, refusedDecisionLine, } from "../channels/contract.js";
|
|
115
|
-
import { ageText, buildPendingQueue, } from "../channels/tagging.js";
|
|
115
|
+
import { recordChannelDecision, recordSurfaceRefusal, refusedDecisionLine, } from "../channels/contract.js";
|
|
116
|
+
import { ageText, attestedPromptOf, buildPendingQueue, } from "../channels/tagging.js";
|
|
116
117
|
import { checkpointOfferFor, checkpointPromptLines, checkpointSignedLines, signCheckpointOffer, } from "./checkpoint-tap.js";
|
|
117
|
-
import { actionRefOf, decidedLine, groupForDigest, isMessageNotModified, isTelegramTerminalState, TelegramChannel, telegramChatEnvFor, telegramTokenEnvFor, TELEGRAM_NOT_RECORDED, TELEGRAM_REVIEW_DENIED, TELEGRAM_REVIEW_RECORDED, TELEGRAM_DEFAULT_API_BASE, TELEGRAM_TERMINAL_HEADLINES, utcClock, } from "../channels/telegram.js";
|
|
118
|
+
import { actionRefOf, decidedLine, groupForDigest, isDeterministicSendRefusal, isMessageNotModified, isTelegramTerminalState, retryAfterMsOf, TelegramChannel, telegramChatEnvFor, telegramTokenEnvFor, TELEGRAM_NOT_RECORDED, TELEGRAM_REVIEW_DENIED, TELEGRAM_REVIEW_RECORDED, TELEGRAM_DEFAULT_API_BASE, TELEGRAM_TERMINAL_HEADLINES, utcClock, } from "../channels/telegram.js";
|
|
118
119
|
import { openReviewCards } from "./audit-card.js";
|
|
119
120
|
import { reviewSample } from "../core/audit.js";
|
|
120
121
|
import { abandonedAfterMs, HOOK_DEFAULT_WAIT_MS, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
|
|
121
|
-
import { telegramDeliveryFor } from "../core/telegram-config.js";
|
|
122
|
+
import { normaliseWebhookUrl, redactWebhookUrl, telegramDeliveryFor, } from "../core/telegram-config.js";
|
|
123
|
+
import { reclaimDetail, takeChannelLease, } from "../core/channel-lease.js";
|
|
122
124
|
import { loadPolicy } from "../core/policy-load.js";
|
|
123
125
|
import { valueFindings } from "../core/instance.js";
|
|
124
126
|
import { defaultSourceRunner } from "../core/env-file.js";
|
|
@@ -126,7 +128,7 @@ import { claimBot, describeOwners, otherOwnersOf, ownedBot, } from "../core/chan
|
|
|
126
128
|
import { actorForSender, recordedSenderFor, senderKeyFrom, } from "../core/sender-identity.js";
|
|
127
129
|
import { recordRefusedGesture, } from "../core/gesture-refusal.js";
|
|
128
130
|
import { attestationRefusal, checkAttestation } from "../core/attest.js";
|
|
129
|
-
import { promptLayoutFor } from "../core/prompt-layout.js";
|
|
131
|
+
import { promptLayoutFor, promptSayFor, promptStyleFor } from "../core/prompt-layout.js";
|
|
130
132
|
import { passphraseEnvFor } from "../core/vault.js";
|
|
131
133
|
import { isAttestationActionKey, proposalRecords, proposalState, } from "../core/policy-proposal.js";
|
|
132
134
|
import { payloadOf, readVerifiedRecords, requestState } from "../core/state.js";
|
|
@@ -146,6 +148,8 @@ const LISTEN_FLAGS = {
|
|
|
146
148
|
"--as": "string",
|
|
147
149
|
"--payloads": "string",
|
|
148
150
|
"--api-base": "string",
|
|
151
|
+
/** APRV-456: refuse a tap on a button this process is not holding. */
|
|
152
|
+
"--no-stale-copy": "boolean",
|
|
149
153
|
"--poll-timeout": "string",
|
|
150
154
|
"--once": "boolean",
|
|
151
155
|
/** APRV-390: start on a credential this instance did not configure. */
|
|
@@ -216,38 +220,164 @@ function env(name) {
|
|
|
216
220
|
return value === undefined || value.trim().length === 0 ? null : value.trim();
|
|
217
221
|
}
|
|
218
222
|
/**
|
|
219
|
-
* Claim this listener's bot before it polls (APRV-390).
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
223
|
+
* Claim this listener's bot before it polls (APRV-390, extended by APRV-424).
|
|
224
|
+
*
|
|
225
|
+
* Three questions, in order, and each one has its own authority:
|
|
226
|
+
*
|
|
227
|
+
* 1. **Does anything else in THIS gate already hold the Telegram transport?**
|
|
228
|
+
* A lockfile in the gate's own `daemon/` directory (`core/channel-lease.ts`)
|
|
229
|
+
* answers it, and the answer is per PROCESS. This is the check the review of
|
|
230
|
+
* APRV-424 added, and the hole it closes is the one neither of the other two
|
|
231
|
+
* could see: an `approval up` long-polling and an `approval channel telegram
|
|
232
|
+
* webhook` started in the same project pass both of them — `getWebhookInfo`
|
|
233
|
+
* is empty while the poller is the one running, and the ownership registry
|
|
234
|
+
* compares instance ids, which are equal because it IS the same instance —
|
|
235
|
+
* and then each runs its own dispatch cycle over its own state against one
|
|
236
|
+
* log, so every request reaches the phone twice under two nonces and only
|
|
237
|
+
* one copy can resolve a tap.
|
|
238
|
+
* 2. **What is the Bot API delivering this bot's updates to?** `getWebhookInfo`
|
|
239
|
+
* answers it, and it is the only thing that can see a webhook registered
|
|
240
|
+
* from another machine. A webhook beats a poller outright — `getUpdates` is
|
|
241
|
+
* refused while one is set — so any registration refuses a poller.
|
|
242
|
+
* 3. **Does another INSTANCE on this machine hold this bot?** `getMe` plus the
|
|
243
|
+
* per-machine registry answers it (APRV-390's incident: two gates, one
|
|
244
|
+
* token, two names, a 409 on every poll).
|
|
245
|
+
*
|
|
246
|
+
* ## An unreachable Bot API: fail-soft for a poller, fail-closed for a webhook
|
|
247
|
+
*
|
|
248
|
+
* A `getMe` or a `getWebhookInfo` that fails says nothing about ownership. The
|
|
249
|
+
* machine may be behind a captive portal, the API may be rate-limiting, the
|
|
250
|
+
* network may be down for four seconds. For a POLLER, refusing on that would
|
|
251
|
+
* mean a transient network fault took the phone channel down until somebody
|
|
252
|
+
* noticed, in exchange for no safety: an unreachable Bot API is also a
|
|
253
|
+
* `getUpdates` that cannot conflict with anything, and the HTTP 409 the loop
|
|
254
|
+
* already reports is the backstop. So the poller says what happened and starts.
|
|
255
|
+
*
|
|
256
|
+
* For the WEBHOOK VERB there is no such backstop, and the review of APRV-424
|
|
257
|
+
* turned this direction around. Its next act is `setWebhook`, which overwrites
|
|
258
|
+
* whatever registration exists without asking, so proceeding on an unknown
|
|
259
|
+
* answer is proceeding to take another host's taps with no error anywhere. A
|
|
260
|
+
* probe it could not make is therefore a refusal (`webhook-probe-failed`), and
|
|
261
|
+
* the operator retries or passes `--reclaim` deliberately.
|
|
238
262
|
*/
|
|
239
|
-
export async function claimListenerBot(setup, report) {
|
|
263
|
+
export async function claimListenerBot(setup, report, options = {}) {
|
|
264
|
+
const mode = options.mode ?? (options.webhookUrl === undefined ? "poll" : "webhook");
|
|
265
|
+
// The local, deterministic question first: it needs no network, and a
|
|
266
|
+
// refusal here is one an operator can act on without waiting for a round
|
|
267
|
+
// trip. Released again below if a later check refuses, so a refused start
|
|
268
|
+
// never leaves a lease behind for the next one to reclaim.
|
|
269
|
+
const leased = takeChannelLease(setup.logPath, mode, {
|
|
270
|
+
note: (message) => report(`approval: ${message}`),
|
|
271
|
+
});
|
|
272
|
+
if (!leased.ok) {
|
|
273
|
+
return {
|
|
274
|
+
ok: false,
|
|
275
|
+
code: leased.code,
|
|
276
|
+
message: leased.message,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
if (leased.reclaimed !== null && leased.reclaimedBecause !== null) {
|
|
280
|
+
report(`approval: ${reclaimDetail(leased.reclaimed, leased.reclaimedBecause)}`);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Did THIS gate's own webhook runner die without removing its registration?
|
|
284
|
+
*
|
|
285
|
+
* The evidence is the lease this take just reclaimed: a record in this
|
|
286
|
+
* gate's own lockfile, written by a webhook runner, whose process is no
|
|
287
|
+
* longer the one holding it. A SIGKILL leaves exactly that, plus a
|
|
288
|
+
* registration at the url the dead process had registered, and without this
|
|
289
|
+
* every restart would refuse `webhook-registered` until an operator baked
|
|
290
|
+
* `--reclaim` into the unit file — which would retire finding 6's protection
|
|
291
|
+
* for good, in exchange for a crash recovery.
|
|
292
|
+
*
|
|
293
|
+
* It is deliberately narrow. Same gate (it is the same lockfile), same
|
|
294
|
+
* transport (a dead poller proves nothing about a registration), the process
|
|
295
|
+
* demonstrably GONE, and the url still has to match after normalisation.
|
|
296
|
+
* Anything else keeps refusing.
|
|
297
|
+
*
|
|
298
|
+
* "Gone" is the only reclaim reason that counts (third review, finding 2).
|
|
299
|
+
* The other two are reclaims on a judgement about a NUMBER rather than a
|
|
300
|
+
* death: `foreign` means a pid this process cannot signal, and `recycled`
|
|
301
|
+
* means a pid whose process started after the lease was written. Both are
|
|
302
|
+
* the right call for the lease, which only has to stop being held; neither
|
|
303
|
+
* is evidence that the webhook runner that registered this url has stopped
|
|
304
|
+
* running, and on a shared machine a foreign pid is as likely to be the
|
|
305
|
+
* OTHER host's live runner. So they keep the refusal and the `--reclaim`
|
|
306
|
+
* demand.
|
|
307
|
+
*/
|
|
308
|
+
const ownWebhookDied = leased.reclaimed !== null &&
|
|
309
|
+
leased.reclaimed.mode === "webhook" &&
|
|
310
|
+
leased.reclaimedBecause === "gone";
|
|
311
|
+
const release = () => leased.lease.release();
|
|
312
|
+
const refuse = (code, message) => {
|
|
313
|
+
release();
|
|
314
|
+
return { ok: false, code, message };
|
|
315
|
+
};
|
|
240
316
|
let identity;
|
|
241
317
|
try {
|
|
242
318
|
identity = await setup.channel.identify();
|
|
243
319
|
}
|
|
244
320
|
catch (cause) {
|
|
245
|
-
|
|
246
|
-
|
|
321
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
322
|
+
if (mode === "webhook") {
|
|
323
|
+
// Fail closed: see the module note above. `setWebhook` would overwrite a
|
|
324
|
+
// registration this process could not see.
|
|
325
|
+
return refuse("webhook-probe-failed", `telegram getMe could not be reached (${detail}), so which bot this token names and what is already receiving its updates are both unknown. This verb registers a webhook, and setWebhook overwrites whatever is registered without reporting it, so it does not run on an unknown answer: fix the connection and start again, or pass --reclaim to take the bot deliberately`);
|
|
326
|
+
}
|
|
327
|
+
report(`approval: telegram getMe could not be reached (${detail}), so which bot this token names is unknown and no ownership was recorded; starting anyway`);
|
|
328
|
+
return { ok: true, lease: leased.lease };
|
|
247
329
|
}
|
|
248
330
|
if (identity.id.length === 0) {
|
|
331
|
+
if (mode === "webhook") {
|
|
332
|
+
return refuse("webhook-probe-failed", "telegram getMe answered without a bot id, so which bot this token names is unknown. This verb registers a webhook over whatever is already registered, and will not do that on an unknown answer");
|
|
333
|
+
}
|
|
249
334
|
report("approval: telegram getMe answered without a bot id, so no ownership was recorded; starting anyway");
|
|
250
|
-
return { ok: true };
|
|
335
|
+
return { ok: true, lease: leased.lease };
|
|
336
|
+
}
|
|
337
|
+
// APRV-424, and before the ownership claim: the registry answers "which
|
|
338
|
+
// process on THIS machine holds this bot", and the Bot API answers "what is
|
|
339
|
+
// this bot's updates going to at all". A webhook beats both — `getUpdates`
|
|
340
|
+
// is refused while one is set — so a listener started here would receive
|
|
341
|
+
// nothing whoever else is or is not running locally.
|
|
342
|
+
const hooked = await webhookOwner(setup);
|
|
343
|
+
if (hooked.state === "unknown") {
|
|
344
|
+
if (mode === "webhook") {
|
|
345
|
+
return refuse("webhook-probe-failed", `telegram getWebhookInfo could not be reached (${hooked.detail}), so whether another host's webhook holds this bot is unknown. setWebhook would overwrite it without saying so, and the displaced receiver would simply stop being posted to, so this verb refuses rather than guessing: try again, or pass --reclaim to register over whatever is there`);
|
|
346
|
+
}
|
|
347
|
+
// The poller's documented fail-soft (see the module note): what a missed
|
|
348
|
+
// probe costs is the clear refusal, and the 409 path is still there.
|
|
349
|
+
report(`approval: telegram getWebhookInfo could not be reached (${hooked.detail}), so whether a webhook holds this bot is unknown; starting anyway`);
|
|
350
|
+
}
|
|
351
|
+
if (hooked.state === "registered") {
|
|
352
|
+
const queued = hooked.pendingUpdateCount === 0
|
|
353
|
+
? ""
|
|
354
|
+
: ` (${String(hooked.pendingUpdateCount)} update(s) are queued for it)`;
|
|
355
|
+
// Origin plus a redacted path, never the url as the Bot API spelled it:
|
|
356
|
+
// this is somebody else's registration, its path may itself be a bearer
|
|
357
|
+
// value (review finding 10), and the origin is what tells hosts apart.
|
|
358
|
+
const shown = redactWebhookUrl(hooked.url);
|
|
359
|
+
if (options.webhookUrl === undefined) {
|
|
360
|
+
return refuse("webhook-registered", `telegram ${identity.username} delivers its updates to a webhook at ${shown}, and the Bot API refuses getUpdates while one is set, so this listener would receive no tap at all${queued}. Long polling and a webhook are alternatives per bot: stop the \`approval channel telegram webhook\` process, which removes the webhook as it exits, or give this instance its own bot with \`approval setup channel telegram\``);
|
|
361
|
+
}
|
|
362
|
+
const mine = normaliseWebhookUrl(options.webhookUrl);
|
|
363
|
+
const theirs = normaliseWebhookUrl(hooked.url);
|
|
364
|
+
const same = mine !== null && theirs !== null && mine === theirs;
|
|
365
|
+
if (same && ownWebhookDied) {
|
|
366
|
+
// The restart case, and the only one that needs no flag: this gate's own
|
|
367
|
+
// webhook runner was killed without the chance to call `deleteWebhook`,
|
|
368
|
+
// and what is registered is the url it left behind.
|
|
369
|
+
report(`approval: the webhook at ${shown} is the one this gate's own runner registered before it died (its lease was reclaimed from pid ${String(leased.reclaimed?.pid ?? 0)}); re-registering the same url, which needs no --reclaim`);
|
|
370
|
+
}
|
|
371
|
+
else if (options.reclaim !== true) {
|
|
372
|
+
return refuse("webhook-registered", `telegram ${identity.username} already delivers its updates to a webhook at ${shown}${queued}. ${same
|
|
373
|
+
? "That is the url this process was asked to register, which looks like a restart and may not be one: two hosts running one configuration both read this as their own, the second overwrites the first's secret_token, and from then on every real tap reaches the first host as a forgery"
|
|
374
|
+
: "This process was asked to register a different url, and a bot has exactly one webhook: registering would silently take the taps away from whatever is serving that one"}. Telegram allows one webhook per bot and keeps the last registration. Stop the other process, or pass --reclaim to register over it deliberately`);
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
report(same
|
|
378
|
+
? `approval: --reclaim: re-registering ${shown}, which is the url already registered for ${identity.username}`
|
|
379
|
+
: `approval: --reclaim: registering over the webhook at ${shown}; whatever is serving it stops being posted to, because Telegram keeps only the last registration for ${identity.username}`);
|
|
380
|
+
}
|
|
251
381
|
}
|
|
252
382
|
const claim = claimBot(setup.logPath, {
|
|
253
383
|
channel: "telegram",
|
|
@@ -257,16 +387,28 @@ export async function claimListenerBot(setup, report) {
|
|
|
257
387
|
});
|
|
258
388
|
if (!claim.ok) {
|
|
259
389
|
if (!setup.allowCrossInstance) {
|
|
260
|
-
return
|
|
390
|
+
return refuse("bot-owned-elsewhere", claim.message);
|
|
261
391
|
}
|
|
262
392
|
// The deliberate case. It says what it is doing, and it does NOT record a
|
|
263
393
|
// second claim: the registry answers "who owns this bot", and two owners
|
|
264
394
|
// is the state it exists to report rather than a state to write down.
|
|
265
395
|
report(`approval: --allow-cross-instance: starting anyway — ${claim.message}`);
|
|
266
|
-
return { ok: true };
|
|
396
|
+
return { ok: true, lease: leased.lease };
|
|
267
397
|
}
|
|
268
398
|
report(`approval: telegram ${identity.username} (bot id ${identity.id}) is this instance's own; no update was consumed by this check`);
|
|
269
|
-
return { ok: true };
|
|
399
|
+
return { ok: true, lease: leased.lease };
|
|
400
|
+
}
|
|
401
|
+
async function webhookOwner(setup) {
|
|
402
|
+
let info;
|
|
403
|
+
try {
|
|
404
|
+
info = await setup.channel.webhookInfo();
|
|
405
|
+
}
|
|
406
|
+
catch (cause) {
|
|
407
|
+
return { state: "unknown", detail: cause instanceof Error ? cause.message : String(cause) };
|
|
408
|
+
}
|
|
409
|
+
return info.url.length === 0
|
|
410
|
+
? { state: "none" }
|
|
411
|
+
: { state: "registered", url: info.url, pendingUpdateCount: info.pendingUpdateCount };
|
|
270
412
|
}
|
|
271
413
|
/**
|
|
272
414
|
* What to append to a runtime 409, naming the other instance if one is known.
|
|
@@ -343,6 +485,54 @@ export const LISTEN_REFUSAL_CODES = [
|
|
|
343
485
|
* which two gates are involved instead of an HTTP 409 loop.
|
|
344
486
|
*/
|
|
345
487
|
"bot-owned-elsewhere",
|
|
488
|
+
/**
|
|
489
|
+
* This bot's updates are being delivered to a webhook (APRV-424).
|
|
490
|
+
*
|
|
491
|
+
* The two transports are alternatives per bot: the Bot API refuses
|
|
492
|
+
* `getUpdates` outright while a webhook is set, so a listener that started
|
|
493
|
+
* here would print a 409 every few seconds and receive not one tap. Refused
|
|
494
|
+
* BEFORE the first poll, in the same preflight and for the same reason
|
|
495
|
+
* `bot-owned-elsewhere` is, so the operator reads which transport owns the
|
|
496
|
+
* bot instead of a status code.
|
|
497
|
+
*
|
|
498
|
+
* Since the review of APRV-424 it carries a second, local source: the
|
|
499
|
+
* transport lease in this gate's own `daemon/` directory, when the process
|
|
500
|
+
* holding it is a webhook runner. Same fact, same repair, so the same code.
|
|
501
|
+
*/
|
|
502
|
+
"webhook-registered",
|
|
503
|
+
/**
|
|
504
|
+
* A long-poll listener in THIS gate already holds the Telegram transport
|
|
505
|
+
* (APRV-424 review finding 1).
|
|
506
|
+
*
|
|
507
|
+
* The counterpart to `webhook-registered`, from the same lease. It is what
|
|
508
|
+
* the webhook verb meets when an `approval up` or an `approval channel
|
|
509
|
+
* telegram listen` is already running in the same project — the case
|
|
510
|
+
* `getWebhookInfo` cannot see (nothing is registered yet) and the ownership
|
|
511
|
+
* registry cannot see either (it is the same instance). Two of them on one
|
|
512
|
+
* log put every prompt on the phone twice, under two nonces, and only one
|
|
513
|
+
* copy can resolve a tap.
|
|
514
|
+
*/
|
|
515
|
+
"telegram-poller-running",
|
|
516
|
+
/**
|
|
517
|
+
* The transport lease could not be taken at all (APRV-424 review finding 1).
|
|
518
|
+
*
|
|
519
|
+
* A `daemon/` directory that cannot be created, a lockfile that cannot be
|
|
520
|
+
* written. Unlike the bot registry, whose absence is the status quo, this
|
|
521
|
+
* file is the only thing keeping one gate from running two transports, so a
|
|
522
|
+
* gate that cannot hold one does not start.
|
|
523
|
+
*/
|
|
524
|
+
"telegram-lease-unavailable",
|
|
525
|
+
/**
|
|
526
|
+
* The webhook verb could not learn what already holds this bot (APRV-424
|
|
527
|
+
* review finding 8).
|
|
528
|
+
*
|
|
529
|
+
* `getMe` or `getWebhookInfo` failed, and the next act would have been a
|
|
530
|
+
* `setWebhook` that overwrites a registration nobody saw. A poller keeps its
|
|
531
|
+
* fail-soft on the same failure, because an unreachable Bot API is also a
|
|
532
|
+
* `getUpdates` that cannot conflict and the HTTP 409 loop is its backstop;
|
|
533
|
+
* this verb has no backstop, so it refuses.
|
|
534
|
+
*/
|
|
535
|
+
"webhook-probe-failed",
|
|
346
536
|
];
|
|
347
537
|
/**
|
|
348
538
|
* Everything that can fail without touching the network, in order.
|
|
@@ -457,6 +647,22 @@ export function prepareListen(request) {
|
|
|
457
647
|
// default, because a layout is not a permission and an unrelated typo in a
|
|
458
648
|
// class rule must not silently redecorate a phone screen.
|
|
459
649
|
layout: promptLayoutFor(policyLoad, "telegram"),
|
|
650
|
+
// APRV-489. The card style and the operator's plain-words declarations.
|
|
651
|
+
// Fix round 2 (S4, and the security follow-up on it): resolved for EACH
|
|
652
|
+
// delivery, and only from ATTESTED bytes: `attestedPromptOf` reads the
|
|
653
|
+
// policy file once, requires those bytes to hash to the latest attestation
|
|
654
|
+
// in the verified log, and parses style and `say` from the same buffer. A
|
|
655
|
+
// re-attested setting reaches the next card without a restart; an edited,
|
|
656
|
+
// unattested file draws the technical card. The two fixed values below are
|
|
657
|
+
// only what a channel without the resolver would use.
|
|
658
|
+
promptStyle: promptStyleFor(policyLoad, "telegram"),
|
|
659
|
+
say: promptSayFor(policyLoad, "telegram"),
|
|
660
|
+
promptFor: () => attestedPromptOf(request.logPath, { policy: request.policy }, "telegram"),
|
|
661
|
+
// APRV-456. From the launch flags alone: a relayed channel (an --api-base
|
|
662
|
+
// that is not the Bot API) or --no-stale-copy turns APRV-196's fallback
|
|
663
|
+
// off. The policy loaded above is deliberately not consulted, so nothing
|
|
664
|
+
// an agent can propose into a policy file can turn it back on.
|
|
665
|
+
staleCopy: staleCopyFor(request.apiBase, request.noStaleCopy === true),
|
|
460
666
|
};
|
|
461
667
|
return {
|
|
462
668
|
ok: true,
|
|
@@ -588,6 +794,8 @@ function setUp(argv, streams, cwd) {
|
|
|
588
794
|
as: stringFlag(flags, "--as"),
|
|
589
795
|
payloads: payloadsFlag === null ? null : absolute(payloadsFlag, cwd),
|
|
590
796
|
apiBase: stringFlag(flags, "--api-base"),
|
|
797
|
+
// APRV-456.
|
|
798
|
+
noStaleCopy: boolFlag(flags, "--no-stale-copy"),
|
|
591
799
|
pollTimeout: stringFlag(flags, "--poll-timeout"),
|
|
592
800
|
once: boolFlag(flags, "--once"),
|
|
593
801
|
json,
|
|
@@ -664,15 +872,47 @@ export const DISPATCH_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
|
664
872
|
* two apart, having deliberately kept nothing that would let it, and a first
|
|
665
873
|
* start that claimed to be a restart would be this channel's own text lying
|
|
666
874
|
* about the system's history.
|
|
875
|
+
*
|
|
876
|
+
* **With the stale-copy fallback off (APRV-456)** the second line says the
|
|
877
|
+
* opposite, because the earlier copies' buttons no longer decide anything: a
|
|
878
|
+
* relayed listener refuses a nonce it is not holding (`nonce-not-issued`), so
|
|
879
|
+
* the banner tells the approver to use the copies below it. A banner that kept
|
|
880
|
+
* promising working older copies would be this channel's own text sending the
|
|
881
|
+
* approver to a button that refuses them.
|
|
667
882
|
*/
|
|
668
|
-
export function bannerLines(pending) {
|
|
883
|
+
export function bannerLines(pending, staleCopy = true) {
|
|
669
884
|
const plural = pending === 1 ? "request" : "requests";
|
|
885
|
+
const live = `The ${pending === 1 ? "message" : "messages"} below ${pending === 1 ? "is" : "are"} the live ${pending === 1 ? "copy" : "copies"}.`;
|
|
670
886
|
return [
|
|
671
887
|
`LISTENER STARTED — re-sending ${pending} pending ${plural}.`,
|
|
672
|
-
|
|
888
|
+
staleCopy
|
|
889
|
+
? `${live} If an earlier copy of the same request is further up this chat, its buttons still decide the same request; nothing is decided twice.`
|
|
890
|
+
: `${live} Buttons on earlier copies further up this chat no longer decide anything on this listener; tap the ${pending === 1 ? "copy" : "copies"} below.`,
|
|
673
891
|
"Which requests are pending is read from the log on every cycle, never from this chat.",
|
|
674
892
|
];
|
|
675
893
|
}
|
|
894
|
+
/**
|
|
895
|
+
* Whether APRV-196's stale-copy fallback is on for a listener launched with
|
|
896
|
+
* this `--api-base` and this `--no-stale-copy` (APRV-456).
|
|
897
|
+
*
|
|
898
|
+
* On only for the direct-bot shape: no `--api-base`, or one that names the
|
|
899
|
+
* Bot API itself, and no `--no-stale-copy`. Any other base is treated as a
|
|
900
|
+
* relay, because the fallback's safety rests on only the bot token being able
|
|
901
|
+
* to put a button in front of the approver, and a base this process does not
|
|
902
|
+
* recognise as Telegram's own is one where that cannot be assumed. A
|
|
903
|
+
* self-hosted Bot API server is therefore treated as a relay too: the strict
|
|
904
|
+
* reading, and the cost is only that earlier copies stop deciding.
|
|
905
|
+
*
|
|
906
|
+
* Launch configuration and nothing else. No policy key reads into it and
|
|
907
|
+
* nothing in an update can reach it.
|
|
908
|
+
*/
|
|
909
|
+
export function staleCopyFor(apiBase, noStaleCopy) {
|
|
910
|
+
if (noStaleCopy)
|
|
911
|
+
return false;
|
|
912
|
+
if (apiBase === null)
|
|
913
|
+
return true;
|
|
914
|
+
return apiBase.replace(/\/+$/u, "") === TELEGRAM_DEFAULT_API_BASE;
|
|
915
|
+
}
|
|
676
916
|
// ---------------------------------------------------------------------------
|
|
677
917
|
// Paced delivery (APRV-216) — one question at a time
|
|
678
918
|
// ---------------------------------------------------------------------------
|
|
@@ -854,6 +1094,9 @@ export function newDispatchState() {
|
|
|
854
1094
|
order: [],
|
|
855
1095
|
current: null,
|
|
856
1096
|
delivered: new Map(),
|
|
1097
|
+
terminalOnly: new Set(),
|
|
1098
|
+
offerFailures: new Map(),
|
|
1099
|
+
offersPausedUntilMs: null,
|
|
857
1100
|
summarySent: false,
|
|
858
1101
|
announced: 0,
|
|
859
1102
|
logSize: null,
|
|
@@ -903,7 +1146,14 @@ function terminalDeliveries(setup, state, now) {
|
|
|
903
1146
|
// `granted`, a declined one `rejected`, a superseded one `withdrawn`
|
|
904
1147
|
// (a newer proposal is the live question now), and a lapsed one `expired`.
|
|
905
1148
|
if (isAttestationActionKey(actionKey)) {
|
|
906
|
-
|
|
1149
|
+
// The LATEST proposal under this key (APRV-455 refuter M2). Every
|
|
1150
|
+
// proposal of one policy hash shares `policy.attest:<sha256>`, and since
|
|
1151
|
+
// a relay may propose bytes already in force a second proposal of an
|
|
1152
|
+
// attested hash is ordinary: settling the live one by the first one's
|
|
1153
|
+
// answer would retire an open prompt as "granted" by somebody else.
|
|
1154
|
+
const proposal = proposalRecords(read.records)
|
|
1155
|
+
.filter((entry) => entry.action_key === actionKey)
|
|
1156
|
+
.at(-1);
|
|
907
1157
|
const derived = proposal === undefined ? null : proposalState(read.records, proposal.seq, now);
|
|
908
1158
|
if (derived === null || derived.state === "open")
|
|
909
1159
|
continue;
|
|
@@ -1000,11 +1250,17 @@ export async function dispatchPending(setup, streams, state, now) {
|
|
|
1000
1250
|
digests: [],
|
|
1001
1251
|
pruned: [],
|
|
1002
1252
|
};
|
|
1003
|
-
const
|
|
1004
|
-
if (!
|
|
1005
|
-
result.queueError = { code:
|
|
1253
|
+
const built = buildPendingQueue(setup.logPath, setup.tagOptions, now);
|
|
1254
|
+
if (!built.ok) {
|
|
1255
|
+
result.queueError = { code: built.code, message: built.message };
|
|
1006
1256
|
return result;
|
|
1007
1257
|
}
|
|
1258
|
+
// APRV-425. One ordering, applied once, so every pass below reads the same
|
|
1259
|
+
// sequence: the annotation sweep, the paced walkthrough's seeding, the
|
|
1260
|
+
// collapse, and the digest grouping. `orderPending` drops nothing and decides
|
|
1261
|
+
// nothing, so the SET this cycle works from is `buildPendingQueue`'s exactly;
|
|
1262
|
+
// what changes is which request an approver meets first.
|
|
1263
|
+
const queue = { ...built, requests: orderPending(built.requests, now) };
|
|
1008
1264
|
// APRV-106 (withdrawal) generalized by APRV-113 (every terminal state),
|
|
1009
1265
|
// before the sends. A request this process delivered and that the log now
|
|
1010
1266
|
// says is settled — granted or rejected at any surface, revoked, expired by
|
|
@@ -1080,6 +1336,27 @@ export async function dispatchPending(setup, streams, state, now) {
|
|
|
1080
1336
|
forget(state, actionKey);
|
|
1081
1337
|
result.pruned.push({ action_key: actionKey, reason: "stale" });
|
|
1082
1338
|
}
|
|
1339
|
+
// APRV-442. A tap the gate refused (a wrong account's, `sender-unmapped`, is
|
|
1340
|
+
// the case that found this) disarms its card, and the channel says which
|
|
1341
|
+
// cards it disarmed that way. The card is gone; the REQUEST is not, unless
|
|
1342
|
+
// the verified log says so. A released key the log still calls pending loses
|
|
1343
|
+
// its delivery bookkeeping here, so the sends below offer it again as a
|
|
1344
|
+
// fresh card in this same cycle. One the log has settled is left exactly
|
|
1345
|
+
// where it is, for the terminal annotation pass above to finish on the next
|
|
1346
|
+
// cycle as it always has. Nothing about the refusal changes: it was recorded
|
|
1347
|
+
// by the gate before this ran, and the new card's buttons reach the same gate.
|
|
1348
|
+
for (const actionKey of setup.channel.takeReleased()) {
|
|
1349
|
+
if (!pendingNow.has(actionKey) || !state.delivered.has(actionKey))
|
|
1350
|
+
continue;
|
|
1351
|
+
forget(state, actionKey);
|
|
1352
|
+
// Under `paced` the released card WAS the question in front of the
|
|
1353
|
+
// approver, and a selection that still believed it was showing would
|
|
1354
|
+
// decline to show anything. Clearing it lets the walkthrough re-offer the
|
|
1355
|
+
// request, which is the first pending key in its order not on the phone.
|
|
1356
|
+
if (state.paced.current?.includes(actionKey) === true)
|
|
1357
|
+
state.paced.current = null;
|
|
1358
|
+
result.pruned.push({ action_key: actionKey, reason: "released" });
|
|
1359
|
+
}
|
|
1083
1360
|
for (const skipped of queue.skipped) {
|
|
1084
1361
|
const token = `${skipped.action_key}:${skipped.code}`;
|
|
1085
1362
|
if (state.warned.has(token))
|
|
@@ -1191,7 +1468,7 @@ export async function dispatchPending(setup, streams, state, now) {
|
|
|
1191
1468
|
state.banner.sent = true;
|
|
1192
1469
|
if (announcing) {
|
|
1193
1470
|
try {
|
|
1194
|
-
const deliveryId = await setup.channel.announce(bannerLines(undelivered.length));
|
|
1471
|
+
const deliveryId = await setup.channel.announce(bannerLines(undelivered.length, setup.channel.staleCopyEnabled()));
|
|
1195
1472
|
result.banner = { delivery_id: deliveryId, pending: undelivered.length };
|
|
1196
1473
|
}
|
|
1197
1474
|
catch (cause) {
|
|
@@ -1322,6 +1599,14 @@ async function dispatchReviews(setup, streams, state, result, now) {
|
|
|
1322
1599
|
if (!openSeqs.has(seq))
|
|
1323
1600
|
review.delivered.delete(seq);
|
|
1324
1601
|
}
|
|
1602
|
+
for (const seq of review.terminalOnly) {
|
|
1603
|
+
if (!openSeqs.has(seq))
|
|
1604
|
+
review.terminalOnly.delete(seq);
|
|
1605
|
+
}
|
|
1606
|
+
for (const seq of review.offerFailures.keys()) {
|
|
1607
|
+
if (!openSeqs.has(seq))
|
|
1608
|
+
review.offerFailures.delete(seq);
|
|
1609
|
+
}
|
|
1325
1610
|
if (review.current !== null && !openSeqs.has(review.current))
|
|
1326
1611
|
review.current = null;
|
|
1327
1612
|
// A count the approver was told that is now too high is the number they
|
|
@@ -1331,7 +1616,16 @@ async function dispatchReviews(setup, streams, state, result, now) {
|
|
|
1331
1616
|
return;
|
|
1332
1617
|
if (setup.delivery === "paced" && state.paced.current !== null)
|
|
1333
1618
|
return;
|
|
1334
|
-
|
|
1619
|
+
// PR #614 recheck NF-2: a transient offer failure pauses the walkthrough for
|
|
1620
|
+
// Telegram's `retry_after` or the backoff, whichever is longer. Nothing is
|
|
1621
|
+
// offered, summary included, until it passes.
|
|
1622
|
+
const nowMs = Date.parse(now);
|
|
1623
|
+
if (review.offersPausedUntilMs !== null) {
|
|
1624
|
+
if (Number.isFinite(nowMs) && nowMs < review.offersPausedUntilMs)
|
|
1625
|
+
return;
|
|
1626
|
+
review.offersPausedUntilMs = null;
|
|
1627
|
+
}
|
|
1628
|
+
const nextSeq = review.order.find((seq) => !review.delivered.has(seq) && !review.terminalOnly.has(seq));
|
|
1335
1629
|
if (nextSeq === undefined)
|
|
1336
1630
|
return;
|
|
1337
1631
|
const card = open.find((entry) => entry.sampleSeq === nextSeq);
|
|
@@ -1355,6 +1649,7 @@ async function dispatchReviews(setup, streams, state, result, now) {
|
|
|
1355
1649
|
try {
|
|
1356
1650
|
const deliveryId = await setup.channel.offerReview(card);
|
|
1357
1651
|
review.delivered.set(nextSeq, deliveryId);
|
|
1652
|
+
review.offerFailures.delete(nextSeq);
|
|
1358
1653
|
review.current = nextSeq;
|
|
1359
1654
|
result.reviewCard = {
|
|
1360
1655
|
delivery_id: deliveryId,
|
|
@@ -1374,10 +1669,55 @@ async function dispatchReviews(setup, streams, state, result, now) {
|
|
|
1374
1669
|
}
|
|
1375
1670
|
}
|
|
1376
1671
|
catch (cause) {
|
|
1377
|
-
|
|
1378
|
-
|
|
1672
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
1673
|
+
const attempts = (review.offerFailures.get(nextSeq) ?? 0) + 1;
|
|
1674
|
+
const deterministic = isDeterministicSendRefusal(cause);
|
|
1675
|
+
if (deterministic || attempts >= REVIEW_OFFER_ATTEMPTS) {
|
|
1676
|
+
// PR #614 refutation F4, narrowed by recheck NF-2. The sample stays open,
|
|
1677
|
+
// and this listener stops offering it: either Telegram refused the card
|
|
1678
|
+
// itself, so the next cycle would build the same card from the same log
|
|
1679
|
+
// and meet the same refusal, or the retry budget is spent. The queue
|
|
1680
|
+
// behind it moves on, and the coded line names the repair.
|
|
1681
|
+
review.offerFailures.delete(nextSeq);
|
|
1682
|
+
review.terminalOnly.add(nextSeq);
|
|
1683
|
+
streams.err(`approval: telegram review-offer-failed: could not offer the review of sample seq ${String(nextSeq)} (${actionKey}): ${reason} — ${deterministic
|
|
1684
|
+
? "Telegram refused the card itself, so every resend would meet the same refusal"
|
|
1685
|
+
: `${String(attempts)} attempts failed`}; the sample stays open for a terminal review (approval audit review ${String(nextSeq)} --ok or --deny), and this listener offers the next sample instead\n`);
|
|
1686
|
+
return;
|
|
1687
|
+
}
|
|
1688
|
+
// PR #614 recheck NF-2. Anything else (a 429, a timeout, a 5xx, a network
|
|
1689
|
+
// error) can clear on its own, so the sample keeps its place and the
|
|
1690
|
+
// walkthrough pauses: for Telegram's `retry_after` when it named one, and
|
|
1691
|
+
// never less than the backoff for this sample's attempt count.
|
|
1692
|
+
review.offerFailures.set(nextSeq, attempts);
|
|
1693
|
+
// NF-5: capped, because `retry_after` is the network's number. A huge one
|
|
1694
|
+
// would otherwise park reviews for years, or overflow the Date below and
|
|
1695
|
+
// throw out of a dispatch cycle that is documented never to throw.
|
|
1696
|
+
const waitMs = Math.min(Math.max(reviewOfferBackoffMs(attempts), retryAfterMsOf(cause) ?? 0), REVIEW_OFFER_MAX_WAIT_MS);
|
|
1697
|
+
const base = Number.isFinite(nowMs) ? nowMs : Date.now();
|
|
1698
|
+
review.offersPausedUntilMs = base + waitMs;
|
|
1699
|
+
streams.err(`approval: telegram review-offer-retry: could not offer the review of sample seq ${String(nextSeq)} (${actionKey}), attempt ${String(attempts)} of ${String(REVIEW_OFFER_ATTEMPTS)}: ${reason} — review cards pause until ${new Date(base + waitMs).toISOString()} and then offer it again\n`);
|
|
1379
1700
|
}
|
|
1380
1701
|
}
|
|
1702
|
+
/**
|
|
1703
|
+
* How many failed offers a sample gets before it is left for a terminal review
|
|
1704
|
+
* (PR #614 recheck NF-2). With {@link reviewOfferBackoffMs} the budget spans
|
|
1705
|
+
* about fifteen minutes of a dark or rate-limited chat (1 + 2 + 4 + 8 minutes
|
|
1706
|
+
* between the five attempts), and a Bot API refusal of the card itself skips
|
|
1707
|
+
* it.
|
|
1708
|
+
*/
|
|
1709
|
+
export const REVIEW_OFFER_ATTEMPTS = 5;
|
|
1710
|
+
/**
|
|
1711
|
+
* The longest review cards ever pause after one failed offer, whatever
|
|
1712
|
+
* `retry_after` said (PR #614 recheck NF-5): one hour.
|
|
1713
|
+
*/
|
|
1714
|
+
export const REVIEW_OFFER_MAX_WAIT_MS = 3_600_000;
|
|
1715
|
+
/** The first pause after a transient offer failure; it doubles per attempt. */
|
|
1716
|
+
export const REVIEW_OFFER_BACKOFF_MS = 60_000;
|
|
1717
|
+
/** The pause after the `attempts`-th failed offer of one sample, before `retry_after`. */
|
|
1718
|
+
export function reviewOfferBackoffMs(attempts) {
|
|
1719
|
+
return REVIEW_OFFER_BACKOFF_MS * 2 ** Math.max(0, attempts - 1);
|
|
1720
|
+
}
|
|
1381
1721
|
/**
|
|
1382
1722
|
* How old a pending request must be, on a listener's first cycle, to be one
|
|
1383
1723
|
* nobody is waiting on (APRV-287).
|
|
@@ -1405,6 +1745,119 @@ export const COLLAPSE_STALE_AFTER_MS = abandonedAfterMs(HOOK_DEFAULT_WAIT_MS, HO
|
|
|
1405
1745
|
* save nobody a message. A flood starts at two.
|
|
1406
1746
|
*/
|
|
1407
1747
|
const COLLAPSE_MIN = 2;
|
|
1748
|
+
/**
|
|
1749
|
+
* Pending requests a NEWER pending request has superseded (APRV-425).
|
|
1750
|
+
*
|
|
1751
|
+
* Two requests naming the same payload bytes and the same class are two askings
|
|
1752
|
+
* of one question, and only the newer one has an asker. The shape that produces
|
|
1753
|
+
* them is APRV-287's own: a hook whose wait ran out leaves its request open for
|
|
1754
|
+
* the retry grace, the grace runs out, the hook withdraws it on its NEXT
|
|
1755
|
+
* invocation — and if that next invocation never comes while a later session
|
|
1756
|
+
* asks the same thing, both sit in the queue. Dogfooding produced exactly this
|
|
1757
|
+
* complaint (APRV-118): a live prompt buried behind dead ones, and an approver
|
|
1758
|
+
* rejecting the one they wanted.
|
|
1759
|
+
*
|
|
1760
|
+
* Keyed by `(payload_hash, class)` and not by task, because the task id a
|
|
1761
|
+
* harness adapter mints is fresh per tool call (SPEC.md §10.2), so two askings
|
|
1762
|
+
* of one command never share one. The bytes and the class are what a decision
|
|
1763
|
+
* binds to, which is what makes them the right identity here.
|
|
1764
|
+
*
|
|
1765
|
+
* Computed from the pending set alone, which is re-derived from the verified log
|
|
1766
|
+
* every cycle, so this decides nothing and remembers nothing. A superseded
|
|
1767
|
+
* request is COLLAPSED, never closed: it stays pending in the log, listable, and
|
|
1768
|
+
* decidable from any copy already delivered. Only a human's decision or the
|
|
1769
|
+
* requester's own withdrawal ends it.
|
|
1770
|
+
*/
|
|
1771
|
+
export function supersededPending(requests) {
|
|
1772
|
+
const newest = new Map();
|
|
1773
|
+
const superseded = new Set();
|
|
1774
|
+
for (const request of requests) {
|
|
1775
|
+
// An attestation prompt is never a duplicate of anything: its identity is
|
|
1776
|
+
// the policy bytes it proposes, two proposals of the same bytes are refused
|
|
1777
|
+
// upstream, and one that reached here is the live one.
|
|
1778
|
+
if (request.policy_diff !== undefined)
|
|
1779
|
+
continue;
|
|
1780
|
+
// APRV-445 refutation (S2). A proposal is its own asking, held for days by
|
|
1781
|
+
// design and keyed by the intention it names; another request over the
|
|
1782
|
+
// same bytes is not a newer copy of it, and it is not a newer copy of one.
|
|
1783
|
+
if (isProposalTask(request.task.value))
|
|
1784
|
+
continue;
|
|
1785
|
+
const identity = JSON.stringify([request.payload_hash.value, request.class.value]);
|
|
1786
|
+
const key = request.action_key.value;
|
|
1787
|
+
const at = Date.parse(request.requested_ts.value);
|
|
1788
|
+
const previous = newest.get(identity);
|
|
1789
|
+
if (previous === undefined) {
|
|
1790
|
+
newest.set(identity, { key, at: Number.isNaN(at) ? 0 : at });
|
|
1791
|
+
continue;
|
|
1792
|
+
}
|
|
1793
|
+
// Later wins, and an unreadable instant loses: a request whose age cannot be
|
|
1794
|
+
// established is not the one to keep in front of an approver. Ties go to the
|
|
1795
|
+
// one later in log order, which is the one the list already put second.
|
|
1796
|
+
if (Number.isNaN(at) || at < previous.at) {
|
|
1797
|
+
superseded.add(key);
|
|
1798
|
+
continue;
|
|
1799
|
+
}
|
|
1800
|
+
superseded.add(previous.key);
|
|
1801
|
+
newest.set(identity, { key, at });
|
|
1802
|
+
}
|
|
1803
|
+
return superseded;
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* The pending set in the order an approver should meet it (APRV-425).
|
|
1807
|
+
*
|
|
1808
|
+
* Three rules, in this order, and each is a complaint from running the gate:
|
|
1809
|
+
*
|
|
1810
|
+
* 1. **Attestation prompts last.** `buildPendingQueue` puts them there on
|
|
1811
|
+
* purpose: a policy amendment changes the rules every entry above it was
|
|
1812
|
+
* routed by, so an approver reading top to bottom answers the questions asked
|
|
1813
|
+
* under the current policy before changing what the current policy is. That
|
|
1814
|
+
* placement is not this function's to relitigate, so proposals are lifted
|
|
1815
|
+
* out, ordered among themselves by nothing at all (log order), and put back
|
|
1816
|
+
* at the end.
|
|
1817
|
+
* 2. **Live requests first, newest first.** Live means younger than
|
|
1818
|
+
* {@link COLLAPSE_STALE_AFTER_MS}, the hook's wait plus its retry grace,
|
|
1819
|
+
* which is the same boundary the collapse uses — one number, one meaning of
|
|
1820
|
+
* "somebody may still be holding this". Newest first because the newest is
|
|
1821
|
+
* the one most likely to have a tool call blocked on it right now, and the
|
|
1822
|
+
* observed failure was a live prompt buried behind dead ones.
|
|
1823
|
+
* 3. **Stale requests after them, oldest first.** They are what the collapse
|
|
1824
|
+
* takes on a first cycle, and where the collapse does not apply (fewer than
|
|
1825
|
+
* {@link COLLAPSE_MIN} of them) oldest-first is the order to work through a
|
|
1826
|
+
* backlog in.
|
|
1827
|
+
*
|
|
1828
|
+
* Stable inside each bucket, so log order breaks every tie and the result is
|
|
1829
|
+
* deterministic for one log and one instant. It is an ORDER and nothing else:
|
|
1830
|
+
* no request is dropped, none is decided, and the set that comes out is the set
|
|
1831
|
+
* that went in.
|
|
1832
|
+
*/
|
|
1833
|
+
export function orderPending(requests, now) {
|
|
1834
|
+
const nowMs = Date.parse(now);
|
|
1835
|
+
if (Number.isNaN(nowMs))
|
|
1836
|
+
return requests;
|
|
1837
|
+
const proposals = [];
|
|
1838
|
+
const live = [];
|
|
1839
|
+
const stale = [];
|
|
1840
|
+
requests.forEach((request, index) => {
|
|
1841
|
+
if (request.policy_diff !== undefined) {
|
|
1842
|
+
proposals.push(request);
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
const parsed = Date.parse(request.requested_ts.value);
|
|
1846
|
+
// An unreadable instant is treated as ancient, which sorts it with the
|
|
1847
|
+
// requests nobody is waiting on. That is the strict side: a request whose
|
|
1848
|
+
// age cannot be established must not displace one whose age is known.
|
|
1849
|
+
const at = Number.isNaN(parsed) ? 0 : parsed;
|
|
1850
|
+
// APRV-445 refutation (S2): a proposal waits on a human for as long as its
|
|
1851
|
+
// TTL says (days), and its requester polls rather than blocks, so the
|
|
1852
|
+
// hook's wait-plus-grace boundary says nothing about whether anyone is
|
|
1853
|
+
// still holding it. It is always live.
|
|
1854
|
+
const stalePosition = nowMs - at >= COLLAPSE_STALE_AFTER_MS && !isProposalTask(request.task.value);
|
|
1855
|
+
(stalePosition ? stale : live).push({ request, at, index });
|
|
1856
|
+
});
|
|
1857
|
+
live.sort((a, b) => b.at - a.at || a.index - b.index);
|
|
1858
|
+
stale.sort((a, b) => a.at - b.at || a.index - b.index);
|
|
1859
|
+
return [...live.map((entry) => entry.request), ...stale.map((entry) => entry.request), ...proposals];
|
|
1860
|
+
}
|
|
1408
1861
|
/**
|
|
1409
1862
|
* A DURATION in words, which is not what {@link ageText} renders.
|
|
1410
1863
|
*
|
|
@@ -1419,8 +1872,17 @@ function durationText(ms) {
|
|
|
1419
1872
|
const minutes = Math.round(ms / 60_000);
|
|
1420
1873
|
return `${String(minutes)}m`;
|
|
1421
1874
|
}
|
|
1422
|
-
/**
|
|
1423
|
-
|
|
1875
|
+
/**
|
|
1876
|
+
* The computed lines a collapsed re-delivery leads with (APRV-287).
|
|
1877
|
+
*
|
|
1878
|
+
* `superseded` is the subset a newer pending request replaced (APRV-425), and
|
|
1879
|
+
* the first line states the split rather than claiming every member is old.
|
|
1880
|
+
* Before this the line said "all older than the hook's wait plus its retry
|
|
1881
|
+
* grace", which stopped being true the moment the collapse widened: a message
|
|
1882
|
+
* that overstates what it collapsed is the stale-documentation failure in
|
|
1883
|
+
* message form, and this one is read by the person deciding.
|
|
1884
|
+
*/
|
|
1885
|
+
export function staleLines(requests, now, superseded = new Set()) {
|
|
1424
1886
|
const nowMs = Date.parse(now);
|
|
1425
1887
|
const ages = requests
|
|
1426
1888
|
.map((request) => nowMs - Date.parse(request.requested_ts.value))
|
|
@@ -1431,18 +1893,25 @@ export function staleLines(requests, now) {
|
|
|
1431
1893
|
const cls = request.class.value;
|
|
1432
1894
|
tally.set(cls, (tally.get(cls) ?? 0) + 1);
|
|
1433
1895
|
}
|
|
1896
|
+
const replaced = requests.filter((request) => superseded.has(request.action_key.value)).length;
|
|
1897
|
+
const abandoned = requests.length - replaced;
|
|
1898
|
+
const why = replaced === 0
|
|
1899
|
+
? `all older than the hook's ${durationText(HOOK_DEFAULT_WAIT_MS)} wait plus its ${durationText(HOOK_RETRY_GRACE_MS)} retry grace`
|
|
1900
|
+
: abandoned === 0
|
|
1901
|
+
? "all superseded by a newer pending request for the same bytes and class"
|
|
1902
|
+
: `${String(abandoned)} older than the hook's ${durationText(HOOK_DEFAULT_WAIT_MS)} wait plus its ${durationText(HOOK_RETRY_GRACE_MS)} retry grace, ${String(replaced)} superseded by a newer pending request for the same bytes and class`;
|
|
1434
1903
|
return [
|
|
1435
|
-
`${String(requests.length)} pending requests,
|
|
1904
|
+
`${String(requests.length)} pending requests, ${why}`,
|
|
1436
1905
|
`oldest: ${oldest === null ? "unknown age" : ageText(oldest)}`,
|
|
1437
1906
|
`classes: ${[...tally.entries()]
|
|
1438
1907
|
.map(([cls, count]) => (count === 1 ? cls : `${cls} ×${String(count)}`))
|
|
1439
1908
|
.join(", ")}`,
|
|
1440
|
-
"collapsed into this one message because
|
|
1909
|
+
"collapsed into this one message because nothing is waiting on them; each one stays pending in the log, and a decision on any of them can still authorize an identical retry",
|
|
1441
1910
|
];
|
|
1442
1911
|
}
|
|
1443
1912
|
/**
|
|
1444
1913
|
* Put the requests nobody is waiting on into ONE message, and hand back the
|
|
1445
|
-
* ones that still get a message each (APRV-287).
|
|
1914
|
+
* ones that still get a message each (APRV-287, widened by APRV-425).
|
|
1446
1915
|
*
|
|
1447
1916
|
* Called on a process's first cycle only, which is exactly a daemon start or a
|
|
1448
1917
|
* listener reconnect. Everything it does is bookkeeping in the sense SPEC.md
|
|
@@ -1450,7 +1919,50 @@ export function staleLines(requests, now) {
|
|
|
1450
1919
|
* so a summary that fails to send, or a process that forgets it sent one,
|
|
1451
1920
|
* degrades to showing those requests again, and never to a pending request
|
|
1452
1921
|
* nobody is shown.
|
|
1922
|
+
*
|
|
1923
|
+
* ## Two ways in, one exit (APRV-425)
|
|
1924
|
+
*
|
|
1925
|
+
* A request is collapsed when it is older than the hook's wait plus its retry
|
|
1926
|
+
* grace (nobody is holding it), or when a NEWER pending request names the same
|
|
1927
|
+
* payload bytes and class ({@link supersededPending} — the newer one is the live
|
|
1928
|
+
* asking, whatever the age of either). The second is what answers the observed
|
|
1929
|
+
* complaint that a live prompt arrives buried behind dead ones.
|
|
1930
|
+
*
|
|
1931
|
+
* ## This is where the sent-record is rebuilt from the log (AC3)
|
|
1932
|
+
*
|
|
1933
|
+
* Every collapsed member is written into `state.delivered` and `state.sentAtMs`
|
|
1934
|
+
* below, and the set it is written from is the pending set re-derived from the
|
|
1935
|
+
* verified log. That is the whole of the rebuilding, and it is deliberately not
|
|
1936
|
+
* a second mechanism: the log is the only place the question "what is pending"
|
|
1937
|
+
* is answered.
|
|
1938
|
+
*
|
|
1939
|
+
* What it is NOT is evidence of delivery, and the difference is load-bearing.
|
|
1940
|
+
* Nothing reads this record to conclude that an approver saw anything: a
|
|
1941
|
+
* collapsed request stays pending in the log, is listed by `/queue`, and is
|
|
1942
|
+
* decidable from any copy already delivered, because APRV-196 made
|
|
1943
|
+
* `callback_data` carry a restart-stable action ref so a button on a
|
|
1944
|
+
* pre-restart copy still decides the request. Lose the record and the next
|
|
1945
|
+
* cycle shows the requests again, which is the degradation SPEC.md §10.3
|
|
1946
|
+
* requires and the one this code takes.
|
|
1947
|
+
*/
|
|
1948
|
+
/**
|
|
1949
|
+
* The members a first-cycle collapse would put into one message: stale or
|
|
1950
|
+
* superseded, and never a proposal (APRV-445 refutation, S2). Proposals are
|
|
1951
|
+
* long-lived by design, so after a six-minute outage every one of them would
|
|
1952
|
+
* otherwise land in one digest whose only bulk answer is reject-all.
|
|
1453
1953
|
*/
|
|
1954
|
+
export function collapsibleStale(undecided, now, superseded = supersededPending([...undecided])) {
|
|
1955
|
+
const nowMs = Date.parse(now);
|
|
1956
|
+
if (Number.isNaN(nowMs))
|
|
1957
|
+
return [];
|
|
1958
|
+
return undecided.filter((request) => {
|
|
1959
|
+
if (isProposalTask(request.task.value))
|
|
1960
|
+
return false;
|
|
1961
|
+
const at = Date.parse(request.requested_ts.value);
|
|
1962
|
+
const age = Number.isNaN(at) ? 0 : nowMs - at;
|
|
1963
|
+
return age >= COLLAPSE_STALE_AFTER_MS || superseded.has(request.action_key.value);
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1454
1966
|
async function collapseStale(setup, streams, state, result, undecided, now) {
|
|
1455
1967
|
const nowMs = Date.parse(now);
|
|
1456
1968
|
if (Number.isNaN(nowMs))
|
|
@@ -1459,12 +1971,17 @@ async function collapseStale(setup, streams, state, result, undecided, now) {
|
|
|
1459
1971
|
const at = Date.parse(request.requested_ts.value);
|
|
1460
1972
|
return Number.isNaN(at) ? 0 : nowMs - at;
|
|
1461
1973
|
};
|
|
1462
|
-
|
|
1974
|
+
// Computed over the WHOLE pending set rather than over `undecided`, so a
|
|
1975
|
+
// request this process has already delivered still supersedes an older twin.
|
|
1976
|
+
const superseded = supersededPending(undecided);
|
|
1977
|
+
const stale = collapsibleStale(undecided, now, superseded);
|
|
1463
1978
|
if (stale.length < COLLAPSE_MIN)
|
|
1464
1979
|
return undecided;
|
|
1465
1980
|
let delivered = null;
|
|
1466
1981
|
try {
|
|
1467
|
-
delivered = await setup.channel.notifyStale(stale, {
|
|
1982
|
+
delivered = await setup.channel.notifyStale(stale, {
|
|
1983
|
+
lines: staleLines(stale, now, superseded),
|
|
1984
|
+
});
|
|
1468
1985
|
}
|
|
1469
1986
|
catch (cause) {
|
|
1470
1987
|
delivered = null;
|
|
@@ -1673,7 +2190,7 @@ function report(setup, streams, digestId, members) {
|
|
|
1673
2190
|
* Report a steady-state cycle's problems on stderr. Startup reports its own,
|
|
1674
2191
|
* as exit codes, in {@link runListener}.
|
|
1675
2192
|
*/
|
|
1676
|
-
function reportCycle(result, streams) {
|
|
2193
|
+
export function reportCycle(result, streams) {
|
|
1677
2194
|
if (result.queueError !== undefined) {
|
|
1678
2195
|
streams.err(`approval: telegram cannot read the pending queue (${result.queueError.code}): ${result.queueError.message} — retrying next cycle\n`);
|
|
1679
2196
|
}
|
|
@@ -1685,6 +2202,32 @@ function reportCycle(result, streams) {
|
|
|
1685
2202
|
streams.err(`approval: telegram sendMessage failed for ${failure.action_key} (attempt ${failure.attempts}): ${failure.message} — still pending, retrying next cycle\n`);
|
|
1686
2203
|
}
|
|
1687
2204
|
}
|
|
2205
|
+
/**
|
|
2206
|
+
* Where a tap the channel refused on its own is recorded (APRV-456).
|
|
2207
|
+
*
|
|
2208
|
+
* One `audit.decision_refused`, through `recordSurfaceRefusal`, which never
|
|
2209
|
+
* calls the gate. The operator's terminal says what happened either way,
|
|
2210
|
+
* because a card nobody here sent being tapped on a relayed gate is the line
|
|
2211
|
+
* an operator most needs to see.
|
|
2212
|
+
*/
|
|
2213
|
+
function refusedDecisionHandlerFor(setup, streams) {
|
|
2214
|
+
return ({ decision, refusal }) => {
|
|
2215
|
+
recordSurfaceRefusal(setup.logPath, decision, refusal, { actor: setup.actor, channel: "telegram" }, setup.gateOptions);
|
|
2216
|
+
if (setup.json) {
|
|
2217
|
+
streams.out(`${JSON.stringify({
|
|
2218
|
+
event: "decision",
|
|
2219
|
+
action_key: decision.action_key,
|
|
2220
|
+
decision: decision.decision,
|
|
2221
|
+
ok: false,
|
|
2222
|
+
code: refusal.code,
|
|
2223
|
+
token_issued: false,
|
|
2224
|
+
})}\n`);
|
|
2225
|
+
}
|
|
2226
|
+
else {
|
|
2227
|
+
streams.err(`approval: telegram decision refused (${refusal.code}): ${refusal.message}\n`);
|
|
2228
|
+
}
|
|
2229
|
+
};
|
|
2230
|
+
}
|
|
1688
2231
|
/** The decision handler: the only thing this process does with a button press. */
|
|
1689
2232
|
function handlerFor(setup, streams) {
|
|
1690
2233
|
return (decision) => {
|
|
@@ -1917,12 +2460,18 @@ export function reviewHandlerFor(setup, streams) {
|
|
|
1917
2460
|
headline: TELEGRAM_NOT_RECORDED,
|
|
1918
2461
|
detail: [refusedDecisionLine(resolved.code)],
|
|
1919
2462
|
toast: "Not recorded.",
|
|
2463
|
+
code: resolved.code,
|
|
1920
2464
|
};
|
|
1921
2465
|
}
|
|
1922
2466
|
const result = reviewSample(setup.logPath, { kind: "seq", seq: tap.sampleSeq }, resolved.actor, tap.note ?? null, {
|
|
1923
2467
|
...(setup.gateOptions.policy === undefined ? {} : { policy: setup.gateOptions.policy }),
|
|
1924
|
-
|
|
2468
|
+
// APRV-482: passed only when the human gave one. A grade alone arrives
|
|
2469
|
+
// with none and `reviewSample` refuses it `verdict-required`.
|
|
2470
|
+
...(tap.verdict === undefined ? {} : { verdict: tap.verdict }),
|
|
1925
2471
|
...(tap.reaction === undefined ? {} : { reaction: tap.reaction }),
|
|
2472
|
+
// APRV-481: the card's own statement that it showed the bytes whole,
|
|
2473
|
+
// which `reviewSample` checks against the log before recording it.
|
|
2474
|
+
...(tap.payloadHash === undefined ? {} : { renderedPayloadHash: tap.payloadHash }),
|
|
1926
2475
|
...(resolved.sender === undefined
|
|
1927
2476
|
? {}
|
|
1928
2477
|
: {
|
|
@@ -1942,7 +2491,7 @@ export function reviewHandlerFor(setup, streams) {
|
|
|
1942
2491
|
event: "review",
|
|
1943
2492
|
ok: false,
|
|
1944
2493
|
sample_seq: tap.sampleSeq,
|
|
1945
|
-
verdict: tap.verdict,
|
|
2494
|
+
verdict: tap.verdict ?? null,
|
|
1946
2495
|
reaction: tap.reaction ?? null,
|
|
1947
2496
|
code: result.code,
|
|
1948
2497
|
})}\n`);
|
|
@@ -1952,6 +2501,7 @@ export function reviewHandlerFor(setup, streams) {
|
|
|
1952
2501
|
headline: TELEGRAM_NOT_RECORDED,
|
|
1953
2502
|
detail: [result.code, result.message],
|
|
1954
2503
|
toast: "Not recorded — the card says why.",
|
|
2504
|
+
code: result.code,
|
|
1955
2505
|
};
|
|
1956
2506
|
}
|
|
1957
2507
|
const obligation = result.obligation;
|
|
@@ -1962,7 +2512,7 @@ export function reviewHandlerFor(setup, streams) {
|
|
|
1962
2512
|
seq: result.record.seq,
|
|
1963
2513
|
sample_seq: result.subject.seq,
|
|
1964
2514
|
action_key: result.subject.actionKey,
|
|
1965
|
-
verdict: tap.verdict,
|
|
2515
|
+
verdict: tap.verdict ?? null,
|
|
1966
2516
|
reaction: tap.reaction ?? null,
|
|
1967
2517
|
obligation_seq: obligation === null ? null : obligation.seq,
|
|
1968
2518
|
})}\n`);
|
|
@@ -1971,7 +2521,7 @@ export function reviewHandlerFor(setup, streams) {
|
|
|
1971
2521
|
streams.out(`reviewed sample at seq ${String(result.subject.seq)} (action ${result.subject.actionKey ?? "-"}) at seq ${String(result.record.seq)} by ${setup.actor} via telegram${tap.verdict === "denied" ? " — DENIED" : ""}${tap.reaction === undefined ? "" : ` — reaction: ${tap.reaction} (guidance, not policy)`}\n`);
|
|
1972
2522
|
}
|
|
1973
2523
|
const detail = [
|
|
1974
|
-
`recorded at seq ${String(result.record.seq)} by ${setup.actor} · verdict ${tap.verdict}`,
|
|
2524
|
+
`recorded at seq ${String(result.record.seq)} by ${setup.actor} · verdict ${tap.verdict ?? "-"}`,
|
|
1975
2525
|
...(tap.reaction === undefined
|
|
1976
2526
|
? []
|
|
1977
2527
|
: [`reaction: ${tap.reaction} — guidance, not policy; it changes no verdict and no budget`]),
|
|
@@ -2060,7 +2610,9 @@ clock = () => new Date().toISOString()) {
|
|
|
2060
2610
|
// and `/queue` is the verb that says what that is. Appended rather than
|
|
2061
2611
|
// interleaved, because the two lists answer different questions: one is
|
|
2062
2612
|
// what is waiting on the approver, the other what already happened.
|
|
2063
|
-
|
|
2613
|
+
// APRV-425: the SAME ordering the dispatch cycle uses, because `/queue` is
|
|
2614
|
+
// the approver asking what is waiting and two orders would be two answers.
|
|
2615
|
+
const lines = queueLines(orderPending(queue.requests, now), now, state.paced.current ?? []);
|
|
2064
2616
|
const built = openReviewCards(setup.logPath);
|
|
2065
2617
|
if (built.ok && built.cards.length > 0)
|
|
2066
2618
|
lines.push(...reviewSummaryLines(built.cards, now));
|
|
@@ -2087,7 +2639,7 @@ clock = () => new Date().toISOString()) {
|
|
|
2087
2639
|
: `Review of sample ${String(card)} passed over — this listener sends no further card for it.`,
|
|
2088
2640
|
"Nothing was recorded. The sample is still open: it is listed by `approval audit list` and reviewable with `approval audit review " +
|
|
2089
2641
|
String(card) +
|
|
2090
|
-
"
|
|
2642
|
+
" --ok` (or `--deny`), and the card already in this chat keeps its buttons.",
|
|
2091
2643
|
]);
|
|
2092
2644
|
return;
|
|
2093
2645
|
}
|
|
@@ -2125,9 +2677,30 @@ clock = () => new Date().toISOString()) {
|
|
|
2125
2677
|
* queue from the verified log and re-sends everything still pending, exactly as
|
|
2126
2678
|
* a restarted process would. A duplicate on the phone, never a silence.
|
|
2127
2679
|
*/
|
|
2128
|
-
|
|
2680
|
+
/**
|
|
2681
|
+
* Give the channel every handler the runtime answers a gesture with, and hand
|
|
2682
|
+
* back the fresh dispatch bookkeeping (APRV-424).
|
|
2683
|
+
*
|
|
2684
|
+
* Extracted from {@link startListener} so the webhook runner wires the SAME
|
|
2685
|
+
* four handlers rather than a second set that looks like them. This is the
|
|
2686
|
+
* CLI-side half of the property APRV-424 is about: the transport decides where
|
|
2687
|
+
* an update arrives, and nothing else. A tap that arrives by webhook is
|
|
2688
|
+
* recorded by `handlerFor`'s `recordChannelDecision` — the same actor, the
|
|
2689
|
+
* same sender resolution, the same gate — because it is the same function
|
|
2690
|
+
* object, registered here.
|
|
2691
|
+
*
|
|
2692
|
+
* A FRESH {@link DispatchState} per call, which is the whole of the restart
|
|
2693
|
+
* story: a supervisor that restarts a fallen runner re-derives the pending
|
|
2694
|
+
* queue from the verified log and re-sends everything still pending. A
|
|
2695
|
+
* duplicate on the phone, never a silence.
|
|
2696
|
+
*/
|
|
2697
|
+
export function wireListener(setup, streams) {
|
|
2129
2698
|
const { channel } = setup;
|
|
2130
2699
|
channel.onDecision(handlerFor(setup, streams));
|
|
2700
|
+
// APRV-456. Where a tap the channel refused on its own (`nonce-not-issued`)
|
|
2701
|
+
// is recorded. A separate function from the decision handler because that
|
|
2702
|
+
// one calls the gate and this one must not.
|
|
2703
|
+
channel.onRefusedDecision(refusedDecisionHandlerFor(setup, streams));
|
|
2131
2704
|
// APRV-257. Registered unconditionally, because whether a checkpoint is ever
|
|
2132
2705
|
// OFFERED is the policy's answer and a handler that exists for a prompt
|
|
2133
2706
|
// nobody sends costs nothing. Registering it here is also what makes the
|
|
@@ -2156,6 +2729,11 @@ export function startListener(setup, streams) {
|
|
|
2156
2729
|
if (setup.delivery === "paced") {
|
|
2157
2730
|
channel.onCommand(commandHandlerFor(setup, streams, state));
|
|
2158
2731
|
}
|
|
2732
|
+
return state;
|
|
2733
|
+
}
|
|
2734
|
+
export function startListener(setup, streams) {
|
|
2735
|
+
const { channel } = setup;
|
|
2736
|
+
const state = wireListener(setup, streams);
|
|
2159
2737
|
let stopping = false;
|
|
2160
2738
|
const stop = () => {
|
|
2161
2739
|
stopping = true;
|
|
@@ -2218,6 +2796,11 @@ async function runListener(setup, streams) {
|
|
|
2218
2796
|
finally {
|
|
2219
2797
|
process.off("SIGINT", stop);
|
|
2220
2798
|
process.off("SIGTERM", stop);
|
|
2799
|
+
// The transport lease goes back on every exit, clean or not (APRV-424
|
|
2800
|
+
// review finding 1). A listener that crashed leaves a lockfile whose pid
|
|
2801
|
+
// is gone and the next taker reclaims it; a listener that stopped politely
|
|
2802
|
+
// leaves nothing to reclaim.
|
|
2803
|
+
claimed.lease.release();
|
|
2221
2804
|
}
|
|
2222
2805
|
switch (outcome.kind) {
|
|
2223
2806
|
case "stopped":
|
|
@@ -2352,6 +2935,15 @@ export function commandTelegram(argv, streams, cwd) {
|
|
|
2352
2935
|
switch (sub) {
|
|
2353
2936
|
case "listen":
|
|
2354
2937
|
return commandTelegramListen(rest, streams, cwd);
|
|
2938
|
+
case "webhook":
|
|
2939
|
+
// APRV-424, and a DYNAMIC import on purpose. The webhook runner reaches
|
|
2940
|
+
// back into this module for `prepareListen`, `wireListener` and
|
|
2941
|
+
// `dispatchPending` — it is the same listener with another arrival — so
|
|
2942
|
+
// a static import here would close the dispatcher/dispatched circle.
|
|
2943
|
+
// An ESM cycle is not a compile error; it is a binding that is
|
|
2944
|
+
// `undefined` in one direction on the day initialisation order changes.
|
|
2945
|
+
// `cli/serve.ts` and `cli/mcp.ts` state the same rule.
|
|
2946
|
+
return import("./channel-telegram-webhook.js").then(({ commandTelegramWebhook }) => commandTelegramWebhook(rest, streams, cwd));
|
|
2355
2947
|
case "health":
|
|
2356
2948
|
return commandTelegramHealth(rest, streams, cwd);
|
|
2357
2949
|
default:
|