approval-md 0.0.1 → 0.2.0
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/LICENSE +176 -0
- package/NOTICE +5 -0
- package/README.md +940 -4
- package/SPEC.md +476 -34
- package/cli.js +29 -3
- package/dist/src/adapters/agentmail.d.ts +426 -0
- package/dist/src/adapters/agentmail.js +1200 -0
- package/dist/src/adapters/agentmail.js.map +1 -0
- package/dist/src/adapters/conformance.d.ts +149 -0
- package/dist/src/adapters/conformance.js +461 -0
- package/dist/src/adapters/conformance.js.map +1 -0
- package/dist/src/adapters/contract.d.ts +628 -0
- package/dist/src/adapters/contract.js +1035 -0
- package/dist/src/adapters/contract.js.map +1 -0
- package/dist/src/adapters/email.d.ts +324 -0
- package/dist/src/adapters/email.js +749 -0
- package/dist/src/adapters/email.js.map +1 -0
- package/dist/src/adapters/env-passphrase.d.ts +93 -0
- package/dist/src/adapters/env-passphrase.js +132 -0
- package/dist/src/adapters/env-passphrase.js.map +1 -0
- package/dist/src/adapters/public.d.ts +11 -0
- package/dist/src/adapters/public.js +11 -0
- package/dist/src/adapters/public.js.map +1 -0
- package/dist/src/adapters/registry.d.ts +59 -0
- package/dist/src/adapters/registry.js +77 -0
- package/dist/src/adapters/registry.js.map +1 -0
- package/dist/src/adapters/smtp.d.ts +213 -0
- package/dist/src/adapters/smtp.js +499 -0
- package/dist/src/adapters/smtp.js.map +1 -0
- package/dist/src/adapters/vault-provider.d.ts +114 -0
- package/dist/src/adapters/vault-provider.js +161 -0
- package/dist/src/adapters/vault-provider.js.map +1 -0
- package/dist/src/adapters/zzz.d.ts +66 -0
- package/dist/src/adapters/zzz.js +299 -0
- package/dist/src/adapters/zzz.js.map +1 -0
- package/dist/src/channels/batch.d.ts +109 -0
- package/dist/src/channels/batch.js +121 -0
- package/dist/src/channels/batch.js.map +1 -0
- package/dist/src/channels/cli.d.ts +193 -0
- package/dist/src/channels/cli.js +468 -0
- package/dist/src/channels/cli.js.map +1 -0
- package/dist/src/channels/conformance.d.ts +92 -0
- package/dist/src/channels/conformance.js +445 -0
- package/dist/src/channels/conformance.js.map +1 -0
- package/dist/src/channels/contract.d.ts +623 -0
- package/dist/src/channels/contract.js +494 -0
- package/dist/src/channels/contract.js.map +1 -0
- package/dist/src/channels/payload-view.d.ts +35 -0
- package/dist/src/channels/payload-view.js +43 -0
- package/dist/src/channels/payload-view.js.map +1 -0
- package/dist/src/channels/render-queue.d.ts +149 -0
- package/dist/src/channels/render-queue.js +564 -0
- package/dist/src/channels/render-queue.js.map +1 -0
- package/dist/src/channels/tagging.d.ts +196 -0
- package/dist/src/channels/tagging.js +723 -0
- package/dist/src/channels/tagging.js.map +1 -0
- package/dist/src/channels/telegram.d.ts +1832 -0
- package/dist/src/channels/telegram.js +3190 -0
- package/dist/src/channels/telegram.js.map +1 -0
- package/dist/src/channels/web.d.ts +341 -0
- package/dist/src/channels/web.js +903 -0
- package/dist/src/channels/web.js.map +1 -0
- package/dist/src/cli/adapter.d.ts +90 -0
- package/dist/src/cli/adapter.js +288 -0
- package/dist/src/cli/adapter.js.map +1 -0
- package/dist/src/cli/amend.d.ts +59 -0
- package/dist/src/cli/amend.js +2171 -0
- package/dist/src/cli/amend.js.map +1 -0
- package/dist/src/cli/args.d.ts +43 -0
- package/dist/src/cli/args.js +86 -0
- package/dist/src/cli/args.js.map +1 -0
- package/dist/src/cli/attest.d.ts +41 -0
- package/dist/src/cli/attest.js +307 -0
- package/dist/src/cli/attest.js.map +1 -0
- package/dist/src/cli/audit-card.d.ts +62 -0
- package/dist/src/cli/audit-card.js +201 -0
- package/dist/src/cli/audit-card.js.map +1 -0
- package/dist/src/cli/audit.d.ts +59 -0
- package/dist/src/cli/audit.js +460 -0
- package/dist/src/cli/audit.js.map +1 -0
- package/dist/src/cli/channel-telegram.d.ts +806 -0
- package/dist/src/cli/channel-telegram.js +2063 -0
- package/dist/src/cli/channel-telegram.js.map +1 -0
- package/dist/src/cli/channel-web.d.ts +131 -0
- package/dist/src/cli/channel-web.js +357 -0
- package/dist/src/cli/channel-web.js.map +1 -0
- package/dist/src/cli/channel.d.ts +71 -0
- package/dist/src/cli/channel.js +438 -0
- package/dist/src/cli/channel.js.map +1 -0
- package/dist/src/cli/checkpoint-tap.d.ts +169 -0
- package/dist/src/cli/checkpoint-tap.js +238 -0
- package/dist/src/cli/checkpoint-tap.js.map +1 -0
- package/dist/src/cli/codex.d.ts +2 -0
- package/dist/src/cli/codex.js +172 -0
- package/dist/src/cli/codex.js.map +1 -0
- package/dist/src/cli/coverage.d.ts +61 -0
- package/dist/src/cli/coverage.js +343 -0
- package/dist/src/cli/coverage.js.map +1 -0
- package/dist/src/cli/daemon.d.ts +120 -0
- package/dist/src/cli/daemon.js +631 -0
- package/dist/src/cli/daemon.js.map +1 -0
- package/dist/src/cli/doctor.d.ts +129 -0
- package/dist/src/cli/doctor.js +2762 -0
- package/dist/src/cli/doctor.js.map +1 -0
- package/dist/src/cli/env.d.ts +65 -0
- package/dist/src/cli/env.js +302 -0
- package/dist/src/cli/env.js.map +1 -0
- package/dist/src/cli/execute.d.ts +202 -0
- package/dist/src/cli/execute.js +1682 -0
- package/dist/src/cli/execute.js.map +1 -0
- package/dist/src/cli/exit-codes.d.ts +73 -0
- package/dist/src/cli/exit-codes.js +82 -0
- package/dist/src/cli/exit-codes.js.map +1 -0
- package/dist/src/cli/feedback.d.ts +60 -0
- package/dist/src/cli/feedback.js +205 -0
- package/dist/src/cli/feedback.js.map +1 -0
- package/dist/src/cli/gate-window.d.ts +40 -0
- package/dist/src/cli/gate-window.js +294 -0
- package/dist/src/cli/gate-window.js.map +1 -0
- package/dist/src/cli/gate.d.ts +68 -0
- package/dist/src/cli/gate.js +557 -0
- package/dist/src/cli/gate.js.map +1 -0
- package/dist/src/cli/git-scope.d.ts +190 -0
- package/dist/src/cli/git-scope.js +295 -0
- package/dist/src/cli/git-scope.js.map +1 -0
- package/dist/src/cli/gloss-attach.d.ts +85 -0
- package/dist/src/cli/gloss-attach.js +107 -0
- package/dist/src/cli/gloss-attach.js.map +1 -0
- package/dist/src/cli/gloss-codex-child.d.ts +9 -0
- package/dist/src/cli/gloss-codex-child.js +149 -0
- package/dist/src/cli/gloss-codex-child.js.map +1 -0
- package/dist/src/cli/gloss-codex.d.ts +24 -0
- package/dist/src/cli/gloss-codex.js +255 -0
- package/dist/src/cli/gloss-codex.js.map +1 -0
- package/dist/src/cli/gloss-options.d.ts +42 -0
- package/dist/src/cli/gloss-options.js +79 -0
- package/dist/src/cli/gloss-options.js.map +1 -0
- package/dist/src/cli/gloss.d.ts +265 -0
- package/dist/src/cli/gloss.js +362 -0
- package/dist/src/cli/gloss.js.map +1 -0
- package/dist/src/cli/help.d.ts +103 -0
- package/dist/src/cli/help.js +2339 -0
- package/dist/src/cli/help.js.map +1 -0
- package/dist/src/cli/hook-codex.d.ts +78 -0
- package/dist/src/cli/hook-codex.js +167 -0
- package/dist/src/cli/hook-codex.js.map +1 -0
- package/dist/src/cli/hook.d.ts +331 -0
- package/dist/src/cli/hook.js +2849 -0
- package/dist/src/cli/hook.js.map +1 -0
- package/dist/src/cli/import.d.ts +35 -0
- package/dist/src/cli/import.js +175 -0
- package/dist/src/cli/import.js.map +1 -0
- package/dist/src/cli/init.d.ts +84 -0
- package/dist/src/cli/init.js +336 -0
- package/dist/src/cli/init.js.map +1 -0
- package/dist/src/cli/instructions.d.ts +23 -0
- package/dist/src/cli/instructions.js +262 -0
- package/dist/src/cli/instructions.js.map +1 -0
- package/dist/src/cli/journal.d.ts +41 -0
- package/dist/src/cli/journal.js +238 -0
- package/dist/src/cli/journal.js.map +1 -0
- package/dist/src/cli/log-advance.d.ts +287 -0
- package/dist/src/cli/log-advance.js +840 -0
- package/dist/src/cli/log-advance.js.map +1 -0
- package/dist/src/cli/log-anchor.d.ts +176 -0
- package/dist/src/cli/log-anchor.js +387 -0
- package/dist/src/cli/log-anchor.js.map +1 -0
- package/dist/src/cli/log-checkpoint.d.ts +22 -0
- package/dist/src/cli/log-checkpoint.js +128 -0
- package/dist/src/cli/log-checkpoint.js.map +1 -0
- package/dist/src/cli/log-sync.d.ts +243 -0
- package/dist/src/cli/log-sync.js +849 -0
- package/dist/src/cli/log-sync.js.map +1 -0
- package/dist/src/cli/log-verbs.d.ts +16 -0
- package/dist/src/cli/log-verbs.js +360 -0
- package/dist/src/cli/log-verbs.js.map +1 -0
- package/dist/src/cli/long-help.d.ts +70 -0
- package/dist/src/cli/long-help.js +148 -0
- package/dist/src/cli/long-help.js.map +1 -0
- package/dist/src/cli/main.d.ts +77 -0
- package/dist/src/cli/main.js +1206 -0
- package/dist/src/cli/main.js.map +1 -0
- package/dist/src/cli/mcp.d.ts +52 -0
- package/dist/src/cli/mcp.js +306 -0
- package/dist/src/cli/mcp.js.map +1 -0
- package/dist/src/cli/paths.d.ts +56 -0
- package/dist/src/cli/paths.js +79 -0
- package/dist/src/cli/paths.js.map +1 -0
- package/dist/src/cli/payload.d.ts +58 -0
- package/dist/src/cli/payload.js +253 -0
- package/dist/src/cli/payload.js.map +1 -0
- package/dist/src/cli/policy.d.ts +43 -0
- package/dist/src/cli/policy.js +229 -0
- package/dist/src/cli/policy.js.map +1 -0
- package/dist/src/cli/preflight.d.ts +363 -0
- package/dist/src/cli/preflight.js +1175 -0
- package/dist/src/cli/preflight.js.map +1 -0
- package/dist/src/cli/progress.d.ts +78 -0
- package/dist/src/cli/progress.js +112 -0
- package/dist/src/cli/progress.js.map +1 -0
- package/dist/src/cli/prompt.d.ts +209 -0
- package/dist/src/cli/prompt.js +312 -0
- package/dist/src/cli/prompt.js.map +1 -0
- package/dist/src/cli/quickstart.d.ts +46 -0
- package/dist/src/cli/quickstart.js +297 -0
- package/dist/src/cli/quickstart.js.map +1 -0
- package/dist/src/cli/records.d.ts +34 -0
- package/dist/src/cli/records.js +66 -0
- package/dist/src/cli/records.js.map +1 -0
- package/dist/src/cli/render.d.ts +22 -0
- package/dist/src/cli/render.js +132 -0
- package/dist/src/cli/render.js.map +1 -0
- package/dist/src/cli/sandbox.d.ts +51 -0
- package/dist/src/cli/sandbox.js +150 -0
- package/dist/src/cli/sandbox.js.map +1 -0
- package/dist/src/cli/scaffold.d.ts +79 -0
- package/dist/src/cli/scaffold.js +137 -0
- package/dist/src/cli/scaffold.js.map +1 -0
- package/dist/src/cli/setup-adapter.d.ts +137 -0
- package/dist/src/cli/setup-adapter.js +509 -0
- package/dist/src/cli/setup-adapter.js.map +1 -0
- package/dist/src/cli/setup-channel.d.ts +117 -0
- package/dist/src/cli/setup-channel.js +635 -0
- package/dist/src/cli/setup-channel.js.map +1 -0
- package/dist/src/cli/setup-checkpoint.d.ts +57 -0
- package/dist/src/cli/setup-checkpoint.js +196 -0
- package/dist/src/cli/setup-checkpoint.js.map +1 -0
- package/dist/src/cli/setup-common.d.ts +275 -0
- package/dist/src/cli/setup-common.js +376 -0
- package/dist/src/cli/setup-common.js.map +1 -0
- package/dist/src/cli/setup-flow.d.ts +287 -0
- package/dist/src/cli/setup-flow.js +476 -0
- package/dist/src/cli/setup-flow.js.map +1 -0
- package/dist/src/cli/setup-service.d.ts +96 -0
- package/dist/src/cli/setup-service.js +308 -0
- package/dist/src/cli/setup-service.js.map +1 -0
- package/dist/src/cli/setup.d.ts +202 -0
- package/dist/src/cli/setup.js +473 -0
- package/dist/src/cli/setup.js.map +1 -0
- package/dist/src/cli/style.d.ts +320 -0
- package/dist/src/cli/style.js +469 -0
- package/dist/src/cli/style.js.map +1 -0
- package/dist/src/cli/token.d.ts +39 -0
- package/dist/src/cli/token.js +274 -0
- package/dist/src/cli/token.js.map +1 -0
- package/dist/src/cli/up.d.ts +155 -0
- package/dist/src/cli/up.js +849 -0
- package/dist/src/cli/up.js.map +1 -0
- package/dist/src/cli/usage.d.ts +37 -0
- package/dist/src/cli/usage.js +91 -0
- package/dist/src/cli/usage.js.map +1 -0
- package/dist/src/cli/values.d.ts +40 -0
- package/dist/src/cli/values.js +189 -0
- package/dist/src/cli/values.js.map +1 -0
- package/dist/src/cli/vault.d.ts +59 -0
- package/dist/src/cli/vault.js +362 -0
- package/dist/src/cli/vault.js.map +1 -0
- package/dist/src/cli/verb-registry.d.ts +76 -0
- package/dist/src/cli/verb-registry.js +2341 -0
- package/dist/src/cli/verb-registry.js.map +1 -0
- package/dist/src/cli/wordmark.d.ts +31 -0
- package/dist/src/cli/wordmark.js +52 -0
- package/dist/src/cli/wordmark.js.map +1 -0
- package/dist/src/codex/doctor.d.ts +13 -0
- package/dist/src/codex/doctor.js +41 -0
- package/dist/src/codex/doctor.js.map +1 -0
- package/dist/src/codex/manifest.d.ts +49 -0
- package/dist/src/codex/manifest.js +103 -0
- package/dist/src/codex/manifest.js.map +1 -0
- package/dist/src/codex/templates.d.ts +41 -0
- package/dist/src/codex/templates.js +319 -0
- package/dist/src/codex/templates.js.map +1 -0
- package/dist/src/codex/trust.d.ts +19 -0
- package/dist/src/codex/trust.js +183 -0
- package/dist/src/codex/trust.js.map +1 -0
- package/dist/src/codex/workspace-plan.d.ts +131 -0
- package/dist/src/codex/workspace-plan.js +561 -0
- package/dist/src/codex/workspace-plan.js.map +1 -0
- package/dist/src/core/actor.d.ts +2 -0
- package/dist/src/core/actor.js +5 -0
- package/dist/src/core/actor.js.map +1 -0
- package/dist/src/core/advance-cycle.d.ts +170 -0
- package/dist/src/core/advance-cycle.js +200 -0
- package/dist/src/core/advance-cycle.js.map +1 -0
- package/dist/src/core/agents-md.d.ts +276 -0
- package/dist/src/core/agents-md.js +747 -0
- package/dist/src/core/agents-md.js.map +1 -0
- package/dist/src/core/apply-patch.d.ts +49 -0
- package/dist/src/core/apply-patch.js +266 -0
- package/dist/src/core/apply-patch.js.map +1 -0
- package/dist/src/core/attest.d.ts +420 -0
- package/dist/src/core/attest.js +589 -0
- package/dist/src/core/attest.js.map +1 -0
- package/dist/src/core/audit.d.ts +492 -0
- package/dist/src/core/audit.js +882 -0
- package/dist/src/core/audit.js.map +1 -0
- package/dist/src/core/budgets.d.ts +238 -0
- package/dist/src/core/budgets.js +449 -0
- package/dist/src/core/budgets.js.map +1 -0
- package/dist/src/core/checkpoint.d.ts +500 -0
- package/dist/src/core/checkpoint.js +738 -0
- package/dist/src/core/checkpoint.js.map +1 -0
- package/dist/src/core/child-env.d.ts +88 -0
- package/dist/src/core/child-env.js +86 -0
- package/dist/src/core/child-env.js.map +1 -0
- package/dist/src/core/clock.d.ts +52 -0
- package/dist/src/core/clock.js +43 -0
- package/dist/src/core/clock.js.map +1 -0
- package/dist/src/core/command-class.d.ts +543 -0
- package/dist/src/core/command-class.js +2356 -0
- package/dist/src/core/command-class.js.map +1 -0
- package/dist/src/core/coverage-sources/adapter.d.ts +40 -0
- package/dist/src/core/coverage-sources/adapter.js +71 -0
- package/dist/src/core/coverage-sources/adapter.js.map +1 -0
- package/dist/src/core/coverage-sources/gh.d.ts +48 -0
- package/dist/src/core/coverage-sources/gh.js +136 -0
- package/dist/src/core/coverage-sources/gh.js.map +1 -0
- package/dist/src/core/coverage-sources/git.d.ts +101 -0
- package/dist/src/core/coverage-sources/git.js +269 -0
- package/dist/src/core/coverage-sources/git.js.map +1 -0
- package/dist/src/core/coverage.d.ts +217 -0
- package/dist/src/core/coverage.js +337 -0
- package/dist/src/core/coverage.js.map +1 -0
- package/dist/src/core/credential-spec.d.ts +72 -0
- package/dist/src/core/credential-spec.js +23 -0
- package/dist/src/core/credential-spec.js.map +1 -0
- package/dist/src/core/dark-session.d.ts +331 -0
- package/dist/src/core/dark-session.js +714 -0
- package/dist/src/core/dark-session.js.map +1 -0
- package/dist/src/core/decision-refusal.d.ts +185 -0
- package/dist/src/core/decision-refusal.js +265 -0
- package/dist/src/core/decision-refusal.js.map +1 -0
- package/dist/src/core/env-file.d.ts +450 -0
- package/dist/src/core/env-file.js +837 -0
- package/dist/src/core/env-file.js.map +1 -0
- package/dist/src/core/execute.d.ts +858 -0
- package/dist/src/core/execute.js +1271 -0
- package/dist/src/core/execute.js.map +1 -0
- package/dist/src/core/frontmatter.d.ts +78 -0
- package/dist/src/core/frontmatter.js +100 -0
- package/dist/src/core/frontmatter.js.map +1 -0
- package/dist/src/core/gate-window.d.ts +312 -0
- package/dist/src/core/gate-window.js +506 -0
- package/dist/src/core/gate-window.js.map +1 -0
- package/dist/src/core/gate.d.ts +1364 -0
- package/dist/src/core/gate.js +3002 -0
- package/dist/src/core/gate.js.map +1 -0
- package/dist/src/core/git-run.d.ts +73 -0
- package/dist/src/core/git-run.js +93 -0
- package/dist/src/core/git-run.js.map +1 -0
- package/dist/src/core/harness-version.d.ts +157 -0
- package/dist/src/core/harness-version.js +211 -0
- package/dist/src/core/harness-version.js.map +1 -0
- package/dist/src/core/harness-wait.d.ts +55 -0
- package/dist/src/core/harness-wait.js +58 -0
- package/dist/src/core/harness-wait.js.map +1 -0
- package/dist/src/core/head-retry.d.ts +107 -0
- package/dist/src/core/head-retry.js +121 -0
- package/dist/src/core/head-retry.js.map +1 -0
- package/dist/src/core/instance.d.ts +253 -0
- package/dist/src/core/instance.js +319 -0
- package/dist/src/core/instance.js.map +1 -0
- package/dist/src/core/intake-limits.d.ts +247 -0
- package/dist/src/core/intake-limits.js +350 -0
- package/dist/src/core/intake-limits.js.map +1 -0
- package/dist/src/core/jcs.d.ts +52 -0
- package/dist/src/core/jcs.js +132 -0
- package/dist/src/core/jcs.js.map +1 -0
- package/dist/src/core/journal.d.ts +144 -0
- package/dist/src/core/journal.js +200 -0
- package/dist/src/core/journal.js.map +1 -0
- package/dist/src/core/live-draw.d.ts +436 -0
- package/dist/src/core/live-draw.js +703 -0
- package/dist/src/core/live-draw.js.map +1 -0
- package/dist/src/core/log-reconcile.d.ts +89 -0
- package/dist/src/core/log-reconcile.js +136 -0
- package/dist/src/core/log-reconcile.js.map +1 -0
- package/dist/src/core/log-subscribe.d.ts +36 -0
- package/dist/src/core/log-subscribe.js +162 -0
- package/dist/src/core/log-subscribe.js.map +1 -0
- package/dist/src/core/log.d.ts +278 -0
- package/dist/src/core/log.js +546 -0
- package/dist/src/core/log.js.map +1 -0
- package/dist/src/core/loop.d.ts +274 -0
- package/dist/src/core/loop.js +487 -0
- package/dist/src/core/loop.js.map +1 -0
- package/dist/src/core/md-fence.d.ts +41 -0
- package/dist/src/core/md-fence.js +74 -0
- package/dist/src/core/md-fence.js.map +1 -0
- package/dist/src/core/money.d.ts +147 -0
- package/dist/src/core/money.js +195 -0
- package/dist/src/core/money.js.map +1 -0
- package/dist/src/core/payload-census.d.ts +74 -0
- package/dist/src/core/payload-census.js +146 -0
- package/dist/src/core/payload-census.js.map +1 -0
- package/dist/src/core/payload-store.d.ts +175 -0
- package/dist/src/core/payload-store.js +340 -0
- package/dist/src/core/payload-store.js.map +1 -0
- package/dist/src/core/payload.d.ts +71 -0
- package/dist/src/core/payload.js +80 -0
- package/dist/src/core/payload.js.map +1 -0
- package/dist/src/core/policy-diff.d.ts +292 -0
- package/dist/src/core/policy-diff.js +588 -0
- package/dist/src/core/policy-diff.js.map +1 -0
- package/dist/src/core/policy-expectations.d.ts +199 -0
- package/dist/src/core/policy-expectations.js +394 -0
- package/dist/src/core/policy-expectations.js.map +1 -0
- package/dist/src/core/policy-explain.d.ts +150 -0
- package/dist/src/core/policy-explain.js +258 -0
- package/dist/src/core/policy-explain.js.map +1 -0
- package/dist/src/core/policy-load.d.ts +527 -0
- package/dist/src/core/policy-load.js +536 -0
- package/dist/src/core/policy-load.js.map +1 -0
- package/dist/src/core/policy-match.d.ts +281 -0
- package/dist/src/core/policy-match.js +478 -0
- package/dist/src/core/policy-match.js.map +1 -0
- package/dist/src/core/policy-proposal.d.ts +265 -0
- package/dist/src/core/policy-proposal.js +458 -0
- package/dist/src/core/policy-proposal.js.map +1 -0
- package/dist/src/core/prompt-layout.d.ts +221 -0
- package/dist/src/core/prompt-layout.js +422 -0
- package/dist/src/core/prompt-layout.js.map +1 -0
- package/dist/src/core/protected-path-guard.d.ts +453 -0
- package/dist/src/core/protected-path-guard.js +1566 -0
- package/dist/src/core/protected-path-guard.js.map +1 -0
- package/dist/src/core/registration.d.ts +25 -0
- package/dist/src/core/registration.js +39 -0
- package/dist/src/core/registration.js.map +1 -0
- package/dist/src/core/reindex.d.ts +99 -0
- package/dist/src/core/reindex.js +336 -0
- package/dist/src/core/reindex.js.map +1 -0
- package/dist/src/core/sampler.d.ts +313 -0
- package/dist/src/core/sampler.js +388 -0
- package/dist/src/core/sampler.js.map +1 -0
- package/dist/src/core/sandbox.d.ts +290 -0
- package/dist/src/core/sandbox.js +424 -0
- package/dist/src/core/sandbox.js.map +1 -0
- package/dist/src/core/seal.d.ts +165 -0
- package/dist/src/core/seal.js +290 -0
- package/dist/src/core/seal.js.map +1 -0
- package/dist/src/core/state.d.ts +505 -0
- package/dist/src/core/state.js +1009 -0
- package/dist/src/core/state.js.map +1 -0
- package/dist/src/core/task-file.d.ts +185 -0
- package/dist/src/core/task-file.js +464 -0
- package/dist/src/core/task-file.js.map +1 -0
- package/dist/src/core/telegram-config.d.ts +93 -0
- package/dist/src/core/telegram-config.js +114 -0
- package/dist/src/core/telegram-config.js.map +1 -0
- package/dist/src/core/token.d.ts +409 -0
- package/dist/src/core/token.js +561 -0
- package/dist/src/core/token.js.map +1 -0
- package/dist/src/core/validate.d.ts +138 -0
- package/dist/src/core/validate.js +0 -0
- package/dist/src/core/validate.js.map +1 -0
- package/dist/src/core/values.d.ts +137 -0
- package/dist/src/core/values.js +153 -0
- package/dist/src/core/values.js.map +1 -0
- package/dist/src/core/vault.d.ts +291 -0
- package/dist/src/core/vault.js +612 -0
- package/dist/src/core/vault.js.map +1 -0
- package/dist/src/core/verified-snapshot.d.ts +204 -0
- package/dist/src/core/verified-snapshot.js +506 -0
- package/dist/src/core/verified-snapshot.js.map +1 -0
- package/dist/src/core/verify.d.ts +336 -0
- package/dist/src/core/verify.js +549 -0
- package/dist/src/core/verify.js.map +1 -0
- package/dist/src/core/version.d.ts +8 -0
- package/dist/src/core/version.js +9 -0
- package/dist/src/core/version.js.map +1 -0
- package/dist/src/core/wysiwys.d.ts +370 -0
- package/dist/src/core/wysiwys.js +728 -0
- package/dist/src/core/wysiwys.js.map +1 -0
- package/dist/src/daemon/advance-child.d.ts +39 -0
- package/dist/src/daemon/advance-child.js +78 -0
- package/dist/src/daemon/advance-child.js.map +1 -0
- package/dist/src/daemon/advance.d.ts +466 -0
- package/dist/src/daemon/advance.js +849 -0
- package/dist/src/daemon/advance.js.map +1 -0
- package/dist/src/daemon/audit.d.ts +87 -0
- package/dist/src/daemon/audit.js +90 -0
- package/dist/src/daemon/audit.js.map +1 -0
- package/dist/src/daemon/daemon.d.ts +1180 -0
- package/dist/src/daemon/daemon.js +1988 -0
- package/dist/src/daemon/daemon.js.map +1 -0
- package/dist/src/daemon/dark-session.d.ts +64 -0
- package/dist/src/daemon/dark-session.js +119 -0
- package/dist/src/daemon/dark-session.js.map +1 -0
- package/dist/src/daemon/draw-child.d.ts +36 -0
- package/dist/src/daemon/draw-child.js +132 -0
- package/dist/src/daemon/draw-child.js.map +1 -0
- package/dist/src/daemon/draw.d.ts +154 -0
- package/dist/src/daemon/draw.js +458 -0
- package/dist/src/daemon/draw.js.map +1 -0
- package/dist/src/daemon/git-evidence.d.ts +173 -0
- package/dist/src/daemon/git-evidence.js +345 -0
- package/dist/src/daemon/git-evidence.js.map +1 -0
- package/dist/src/daemon/projection.d.ts +180 -0
- package/dist/src/daemon/projection.js +233 -0
- package/dist/src/daemon/projection.js.map +1 -0
- package/dist/src/daemon/prune.d.ts +207 -0
- package/dist/src/daemon/prune.js +376 -0
- package/dist/src/daemon/prune.js.map +1 -0
- package/dist/src/mcp/http.d.ts +113 -0
- package/dist/src/mcp/http.js +343 -0
- package/dist/src/mcp/http.js.map +1 -0
- package/dist/src/mcp/server.d.ts +265 -0
- package/dist/src/mcp/server.js +602 -0
- package/dist/src/mcp/server.js.map +1 -0
- package/docs/adapter-api.md +106 -0
- package/docs/cli-reference.md +5716 -0
- package/docs/codex-enforced-session.md +30 -0
- package/package.json +53 -4
- package/schema/.gitkeep +0 -0
- package/schema/LICENSE +117 -0
- package/schema/codex-instance.schema.json +82 -0
- package/schema/envelope.schema.json +137 -0
- package/schema/event.schema.json +1811 -0
- package/schema/fixtures/codex-instance/invalid/unpinned-codex-version.json +40 -0
- package/schema/fixtures/codex-instance/valid/canonical.json +40 -0
- package/schema/fixtures/envelope/invalid/action-missing-idempotency-key.json +15 -0
- package/schema/fixtures/envelope/invalid/action-unknown-class-format.json +14 -0
- package/schema/fixtures/envelope/invalid/confidence-out-of-range.json +11 -0
- package/schema/fixtures/envelope/invalid/est-cost-bare-number.json +14 -0
- package/schema/fixtures/envelope/invalid/est-cost-noncanonical-string.json +14 -0
- package/schema/fixtures/envelope/invalid/malformed-assignee.json +10 -0
- package/schema/fixtures/envelope/invalid/malformed-created-by.json +7 -0
- package/schema/fixtures/envelope/invalid/malformed-max-latency.json +11 -0
- package/schema/fixtures/envelope/invalid/malformed-payload-hash.json +14 -0
- package/schema/fixtures/envelope/invalid/max-cost-bare-number.json +11 -0
- package/schema/fixtures/envelope/invalid/missing-origin.json +3 -0
- package/schema/fixtures/envelope/invalid/negative-est-cost.json +14 -0
- package/schema/fixtures/envelope/invalid/unknown-state.json +7 -0
- package/schema/fixtures/envelope/invalid/unknown-top-level-field.json +8 -0
- package/schema/fixtures/envelope/valid/action-payload-hash.json +17 -0
- package/schema/fixtures/envelope/valid/actions-without-budget.json +18 -0
- package/schema/fixtures/envelope/valid/canonical.json +25 -0
- package/schema/fixtures/envelope/valid/minimal.json +7 -0
- package/schema/fixtures/envelope/valid/multi-action-executed.json +30 -0
- package/schema/fixtures/envelope/valid/record-write-stage.json +22 -0
- package/schema/fixtures/event/invalid/approval-granted-agent-actor.json +15 -0
- package/schema/fixtures/event/invalid/approval-granted-empty-batch-delivery-id.json +17 -0
- package/schema/fixtures/event/invalid/approval-granted-fifth-reaction.json +16 -0
- package/schema/fixtures/event/invalid/approval-granted-missing-actor.json +14 -0
- package/schema/fixtures/event/invalid/approval-requested-missing-action-key.json +14 -0
- package/schema/fixtures/event/invalid/approval-withdrawn-agent-policy-drift.json +15 -0
- package/schema/fixtures/event/invalid/approval-withdrawn-missing-reason.json +15 -0
- package/schema/fixtures/event/invalid/approval-withdrawn-system-actor.json +15 -0
- package/schema/fixtures/event/invalid/audit-decision-refused-human-actor.json +17 -0
- package/schema/fixtures/event/invalid/audit-decision-refused-missing-code.json +16 -0
- package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +15 -0
- package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +16 -0
- package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +15 -0
- package/schema/fixtures/event/invalid/bad-actor-prefix.json +15 -0
- package/schema/fixtures/event/invalid/est-cost-bare-number.json +17 -0
- package/schema/fixtures/event/invalid/execution-completed-fabricated-exit-code.json +16 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-empty-id.json +18 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-extra-field.json +19 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-id-not-string.json +18 -0
- package/schema/fixtures/event/invalid/execution-completed-provider-ref-missing-adapter.json +17 -0
- package/schema/fixtures/event/invalid/execution-failed-open-reported-by.json +16 -0
- package/schema/fixtures/event/invalid/execution-indeterminate-open-reason.json +14 -0
- package/schema/fixtures/event/invalid/execution-reconciled-agent-actor.json +17 -0
- package/schema/fixtures/event/invalid/execution-started-negative-env-stripped.json +16 -0
- package/schema/fixtures/event/invalid/gate-bypassed-missing-opened-seq.json +15 -0
- package/schema/fixtures/event/invalid/gate-closed-non-integer-opened-seq.json +13 -0
- package/schema/fixtures/event/invalid/gate-opened-agent-actor.json +16 -0
- package/schema/fixtures/event/invalid/gate-organ-attested-absolute-path.json +14 -0
- package/schema/fixtures/event/invalid/gate-organ-attested-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/gate-organ-attested-missing-organ-path.json +13 -0
- package/schema/fixtures/event/invalid/harness-unknown-kind.json +18 -0
- package/schema/fixtures/event/invalid/harness-version-multiline.json +16 -0
- package/schema/fixtures/event/invalid/log-checkpoint-agent-actor.json +17 -0
- package/schema/fixtures/event/invalid/log-checkpoint-missing-signature.json +16 -0
- package/schema/fixtures/event/invalid/log-checkpoint-short-signed-hash.json +17 -0
- package/schema/fixtures/event/invalid/log-checkpoint-unknown-signature-alg.json +17 -0
- package/schema/fixtures/event/invalid/malformed-ts.json +15 -0
- package/schema/fixtures/event/invalid/missing-alg.json +14 -0
- package/schema/fixtures/event/invalid/missing-hash.json +14 -0
- package/schema/fixtures/event/invalid/non-integer-seq.json +15 -0
- package/schema/fixtures/event/invalid/payload-pruned-human-actor.json +14 -0
- package/schema/fixtures/event/invalid/payload-pruned-missing-hash.json +14 -0
- package/schema/fixtures/event/invalid/policy-declined-agent-actor.json +15 -0
- package/schema/fixtures/event/invalid/policy-proposed-missing-diff.json +19 -0
- package/schema/fixtures/event/invalid/policy-proposed-system-actor.json +26 -0
- package/schema/fixtures/event/invalid/short-hash.json +15 -0
- package/schema/fixtures/event/invalid/unknown-alg.json +15 -0
- package/schema/fixtures/event/invalid/unknown-event-type.json +15 -0
- package/schema/fixtures/event/invalid/unknown-top-level-field.json +16 -0
- package/schema/fixtures/event/valid/approval-expired.json +15 -0
- package/schema/fixtures/event/valid/approval-granted-batch.json +19 -0
- package/schema/fixtures/event/valid/approval-granted-reaction.json +16 -0
- package/schema/fixtures/event/valid/approval-granted.json +15 -0
- package/schema/fixtures/event/valid/approval-rejected.json +15 -0
- package/schema/fixtures/event/valid/approval-requested.json +19 -0
- package/schema/fixtures/event/valid/approval-revoked.json +15 -0
- package/schema/fixtures/event/valid/approval-withdrawn-policy-drift.json +17 -0
- package/schema/fixtures/event/valid/approval-withdrawn.json +16 -0
- package/schema/fixtures/event/valid/audit-decision-refused.json +20 -0
- package/schema/fixtures/event/valid/audit-reviewed-reaction.json +17 -0
- package/schema/fixtures/event/valid/audit-reviewed.json +15 -0
- package/schema/fixtures/event/valid/audit-sampled.json +14 -0
- package/schema/fixtures/event/valid/budget-exceeded.json +21 -0
- package/schema/fixtures/event/valid/envelope-drift.json +16 -0
- package/schema/fixtures/event/valid/execution-completed-harness-report.json +16 -0
- package/schema/fixtures/event/valid/execution-completed-provider-ref.json +18 -0
- package/schema/fixtures/event/valid/execution-completed.json +15 -0
- package/schema/fixtures/event/valid/execution-failed-harness-report.json +16 -0
- package/schema/fixtures/event/valid/execution-failed.json +15 -0
- package/schema/fixtures/event/valid/execution-indeterminate.json +15 -0
- package/schema/fixtures/event/valid/execution-reconciled.json +17 -0
- package/schema/fixtures/event/valid/execution-started-env-stripped.json +17 -0
- package/schema/fixtures/event/valid/execution-started.json +14 -0
- package/schema/fixtures/event/valid/gate-bypassed-harness-version.json +18 -0
- package/schema/fixtures/event/valid/gate-bypassed.json +19 -0
- package/schema/fixtures/event/valid/gate-closed.json +14 -0
- package/schema/fixtures/event/valid/gate-opened.json +16 -0
- package/schema/fixtures/event/valid/gate-organ-attested.json +14 -0
- package/schema/fixtures/event/valid/genesis-null-prev.json +14 -0
- package/schema/fixtures/event/valid/log-checkpoint.json +17 -0
- package/schema/fixtures/event/valid/payload-pruned-orphan.json +13 -0
- package/schema/fixtures/event/valid/payload-pruned.json +17 -0
- package/schema/fixtures/event/valid/policy-declined.json +16 -0
- package/schema/fixtures/event/valid/policy-proposed.json +35 -0
- package/schema/fixtures/event/valid/policy-updated.json +14 -0
- package/schema/fixtures/event/valid/reconciliation-required.json +18 -0
- package/schema/fixtures/event/valid/reconciliation-satisfied.json +17 -0
- package/schema/fixtures/event/valid/route-accepted.json +15 -0
- package/schema/fixtures/event/valid/route-proposed.json +16 -0
- package/schema/fixtures/event/valid/spec-example.json +15 -0
- package/schema/fixtures/event/valid/task-registered-harness-version.json +23 -0
- package/schema/fixtures/event/valid/task-registered.json +14 -0
- package/schema/fixtures/hash/known-answer-pre-121.json +74 -0
- package/schema/fixtures/hash/known-answer.json +74 -0
- package/schema/fixtures/policy/invalid/bad-approval-ttl.json +7 -0
- package/schema/fixtures/policy/invalid/bad-web-port.json +4 -0
- package/schema/fixtures/policy/invalid/checkpoint-key-not-base64.json +6 -0
- package/schema/fixtures/policy/invalid/class-rule-missing-autonomy.json +9 -0
- package/schema/fixtures/policy/invalid/empty-class-key.json +6 -0
- package/schema/fixtures/policy/invalid/live-rate-on-human-only.json +7 -0
- package/schema/fixtures/policy/invalid/malformed-class-key.json +6 -0
- package/schema/fixtures/policy/invalid/missing-version.json +8 -0
- package/schema/fixtures/policy/invalid/negative-limit.json +9 -0
- package/schema/fixtures/policy/invalid/non-numeric-limit.json +9 -0
- package/schema/fixtures/policy/invalid/non-positive-max-pending.json +9 -0
- package/schema/fixtures/policy/invalid/on-expiry-grant.json +8 -0
- package/schema/fixtures/policy/invalid/payload-retention-bare-number.json +4 -0
- package/schema/fixtures/policy/invalid/payload-retention-compound.json +4 -0
- package/schema/fixtures/policy/invalid/payload-retention-fractional.json +4 -0
- package/schema/fixtures/policy/invalid/payload-retention-zero.json +4 -0
- package/schema/fixtures/policy/invalid/protected-paths-escape.json +4 -0
- package/schema/fixtures/policy/invalid/protected-paths-glob.json +4 -0
- package/schema/fixtures/policy/invalid/retro-rate-on-human-only.json +7 -0
- package/schema/fixtures/policy/invalid/retro-rate-on-manual.json +7 -0
- package/schema/fixtures/policy/invalid/retro-rate-zero.json +7 -0
- package/schema/fixtures/policy/invalid/sample-rate-too-high.json +5 -0
- package/schema/fixtures/policy/invalid/sampling-secret-env-empty.json +7 -0
- package/schema/fixtures/policy/invalid/sampling-secret-env-not-string.json +6 -0
- package/schema/fixtures/policy/invalid/skew-tolerance-compound.json +6 -0
- package/schema/fixtures/policy/invalid/unknown-autonomy.json +7 -0
- package/schema/fixtures/policy/invalid/unknown-class-rule-key.json +6 -0
- package/schema/fixtures/policy/invalid/unknown-top-level-key.json +7 -0
- package/schema/fixtures/policy/invalid/vault-passphrase-env-empty.json +6 -0
- package/schema/fixtures/policy/invalid/vault-passphrase-literal.json +6 -0
- package/schema/fixtures/policy/invalid/version-not-string.json +4 -0
- package/schema/fixtures/policy/valid/canonical.json +47 -0
- package/schema/fixtures/policy/valid/checkpoint-keys.json +18 -0
- package/schema/fixtures/policy/valid/class-approvers-limits.json +25 -0
- package/schema/fixtures/policy/valid/class-retro-rate.json +17 -0
- package/schema/fixtures/policy/valid/global-budgets.json +19 -0
- package/schema/fixtures/policy/valid/human-only.json +9 -0
- package/schema/fixtures/policy/valid/minimal.json +6 -0
- package/schema/fixtures/policy/valid/protected-paths.json +10 -0
- package/schema/fixtures/policy/valid/record-namespace.json +13 -0
- package/schema/fixtures/policy/valid/request-volume-limits.json +26 -0
- package/schema/fixtures/policy/valid/retention-and-sampling-secret.json +16 -0
- package/schema/fixtures/policy/valid/skew-tolerance.json +15 -0
- package/schema/fixtures/policy/valid/vault-passphrase-env.json +14 -0
- package/schema/fixtures/policy/valid/wildcards.json +15 -0
- package/schema/fixtures/policy-md/invalid/alias-bomb.md +15 -0
- package/schema/fixtures/policy-md/invalid/no-fence.md +7 -0
- package/schema/fixtures/policy-md/invalid/protected-route-not-a-subclass.md +16 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-autonomy.md +16 -0
- package/schema/fixtures/policy-md/invalid/schema-invalid-read-proof.md +17 -0
- package/schema/fixtures/policy-md/invalid/two-fences.md +19 -0
- package/schema/fixtures/policy-md/invalid/unclosed-fence.md +11 -0
- package/schema/fixtures/policy-md/invalid/wrong-info-string.md +11 -0
- package/schema/fixtures/policy-md/invalid/yaml-syntax-error.md +13 -0
- package/schema/fixtures/policy-md/precedence/both/APPROVAL.md +7 -0
- package/schema/fixtures/policy-md/precedence/both/APPROVALS.md +7 -0
- package/schema/fixtures/policy-md/precedence/fallback-only/APPROVALS.md +7 -0
- package/schema/fixtures/policy-md/valid/canonical.md +50 -0
- package/schema/fixtures/policy-md/valid/daemon-read-proof.md +18 -0
- package/schema/fixtures/policy-md/valid/minimal.md +3 -0
- package/schema/fixtures/policy-md/valid/prose-lookalikes.md +54 -0
- package/schema/fixtures/policy-md/valid/routed-protected-paths.md +49 -0
- package/schema/fixtures/policy-md/valid/with-values.md +79 -0
- package/schema/fixtures/sample-record/invalid/bad-date-time.json +4 -0
- package/schema/fixtures/sample-record/invalid/missing-required-field.json +3 -0
- package/schema/fixtures/sample-record/invalid/unknown-top-level-field.json +5 -0
- package/schema/fixtures/sample-record/invalid/wrong-type.json +4 -0
- package/schema/fixtures/sample-record/valid/minimal.json +4 -0
- package/schema/fixtures/sample-record/valid/with-note.json +5 -0
- package/schema/fixtures/values/invalid/class-shaped.json +9 -0
- package/schema/fixtures/values/invalid/duplicate-entry.json +4 -0
- package/schema/fixtures/values/invalid/non-string-item.json +4 -0
- package/schema/fixtures/values/invalid/over-cap.json +26 -0
- package/schema/fixtures/values/invalid/unknown-key.json +5 -0
- package/schema/fixtures/values/invalid/version-string.json +1 -0
- package/schema/fixtures/values/valid/empty-lists.json +7 -0
- package/schema/fixtures/values/valid/full.json +20 -0
- package/schema/fixtures/values/valid/minimal.json +1 -0
- package/schema/fixtures/values-md/invalid/schema-invalid.md +62 -0
- package/schema/fixtures/values-md/invalid/two-blocks.md +69 -0
- package/schema/fixtures/values-md/invalid/unterminated.md +61 -0
- package/schema/fixtures/values-md/invalid/yaml-error.md +63 -0
- package/schema/fixtures/values-md/valid/absent.md +50 -0
- package/schema/fixtures/values-md/valid/with-values.md +79 -0
- package/schema/policy.schema.json +501 -0
- package/schema/sample-record.schema.json +26 -0
- package/schema/values.schema.json +55 -0
- package/templates/codex/README.md +9 -0
|
@@ -0,0 +1,2063 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `approval channel telegram listen | health` — the runtime half of the
|
|
3
|
+
* Telegram channel (SPEC.md §10.3, APRV-26).
|
|
4
|
+
*
|
|
5
|
+
* As everywhere else in this CLI, **no logic lives here**. The rendering and
|
|
6
|
+
* the Bot API are `channels/telegram.ts`; turning a button press into an event
|
|
7
|
+
* is `channels/contract.ts`'s `recordChannelDecision`, which calls the
|
|
8
|
+
* human-only `decide()` in `core/gate.ts`. This file resolves configuration,
|
|
9
|
+
* builds the pending queue, wires the two together, and chooses an exit code.
|
|
10
|
+
*
|
|
11
|
+
* Three things it does that the channel deliberately cannot:
|
|
12
|
+
*
|
|
13
|
+
* 1. **It reads the environment.** `APPROVAL_TG_TOKEN` and `APPROVAL_TG_CHAT`
|
|
14
|
+
* are read here and passed to the channel as values (SPEC.md §5.1: policy
|
|
15
|
+
* carries the env-var *names*, never the secrets). Nothing in `channels/`
|
|
16
|
+
* touches `process.env`.
|
|
17
|
+
* 2. **It declares who is approving.** The decision is recorded against the
|
|
18
|
+
* human actor from `--as` / `APPROVAL_HUMAN`, never against anything the
|
|
19
|
+
* callback carried. SPEC.md §11: identity in v0.1 is config-declared, the
|
|
20
|
+
* trust boundary is the local machine, and everyone who can reach the
|
|
21
|
+
* configured chat can approve as that actor. This is stated in `--help`
|
|
22
|
+
* because an operator has to be able to see it without reading the source.
|
|
23
|
+
* 3. **It holds the token.** A grant mints a single-use execution token;
|
|
24
|
+
* `recordChannelDecision` returns it to *this* handler, which prints it on
|
|
25
|
+
* **stdout** and never hands it back to the channel. It is never sent to
|
|
26
|
+
* Telegram — see the module doc of `channels/telegram.ts` for why a chat
|
|
27
|
+
* transcript is not a credential store, and for the flag on that decision.
|
|
28
|
+
*
|
|
29
|
+
* ## Payload material — the store, and why `--payloads` still exists
|
|
30
|
+
*
|
|
31
|
+
* SPEC.md §6.2 records a `payload_hash` in the log and never the bytes, and
|
|
32
|
+
* §10.4 requires a channel to present the full payload for a manual action. So
|
|
33
|
+
* the bytes must come from somewhere the runtime can reach. Since APRV-28 that
|
|
34
|
+
* somewhere is the payload store beside the log (`.approval/payloads/`, written
|
|
35
|
+
* by `approval request --payload`), and a listener ordinarily needs no payload
|
|
36
|
+
* flag at all. `--payloads` remains an override for bytes an operator holds
|
|
37
|
+
* elsewhere: a JSON file mapping action key to that action's payload value,
|
|
38
|
+
* consulted before the store. The tagger
|
|
39
|
+
* (`channels/tagging.ts`) re-hashes whatever it is given and refuses anything
|
|
40
|
+
* that does not match the recorded binding, so a wrong or stale file cannot put
|
|
41
|
+
* different bytes in front of an approver than the token will execute — it
|
|
42
|
+
* produces a visible skip instead. Requests whose material is missing are
|
|
43
|
+
* reported on stderr and NOT delivered: a manual request rendered without its
|
|
44
|
+
* payload would be exactly the §10.4 violation the contract refuses.
|
|
45
|
+
*
|
|
46
|
+
* ## Dispatch: where it lives, and why it lives here (APRV-55) — flagged
|
|
47
|
+
*
|
|
48
|
+
* SPEC.md §10.2 lists "dispatches channel notifications" among the daemon's
|
|
49
|
+
* jobs. At v0.1 the reference runtime performs that dispatch **in this
|
|
50
|
+
* listener**, on every poll cycle, and the placement is deliberate:
|
|
51
|
+
*
|
|
52
|
+
* 1. The listener already holds the channel connection (the bot token, the
|
|
53
|
+
* chat id) and the approver identity. The daemon holds neither, and giving
|
|
54
|
+
* it either would put a credential and a human identity into a process
|
|
55
|
+
* whose job is to read files and append events.
|
|
56
|
+
* 2. The daemon is the sole writer of the log; dispatch appends nothing. Moving
|
|
57
|
+
* a read-and-send out of the daemon costs the single-writer stance nothing,
|
|
58
|
+
* because dispatch was never a write.
|
|
59
|
+
* 3. A network round-trip inside the daemon's tick couples the projection loop
|
|
60
|
+
* to Telegram's availability. A slow Bot API would delay TTL expiry and
|
|
61
|
+
* write-back, which are the daemon's actual obligations.
|
|
62
|
+
*
|
|
63
|
+
* So this is an implementation placement, not a change to the daemon's stated
|
|
64
|
+
* role: a later build MAY move dispatch into the daemon (or a supervisor) with
|
|
65
|
+
* no change to the log or to any event shape. SPEC.md §10.3 records the same.
|
|
66
|
+
*
|
|
67
|
+
* ### The cycle
|
|
68
|
+
*
|
|
69
|
+
* {@link dispatchPending} runs before every `getUpdates` — the startup send and
|
|
70
|
+
* every later cycle are the same call with the same state, the startup one
|
|
71
|
+
* merely finding an empty delivered set. Each call **re-derives** the pending
|
|
72
|
+
* queue from the verified log ({@link buildPendingQueue}), so which requests
|
|
73
|
+
* are pending is always the log's answer and never this process's memory. A
|
|
74
|
+
* request appended while the listener is running is therefore delivered on the
|
|
75
|
+
* next cycle, without a restart; a request that was decided or whose TTL lapsed
|
|
76
|
+
* simply stops appearing in the derivation and is never sent.
|
|
77
|
+
*
|
|
78
|
+
* What *is* remembered, and only in {@link DispatchState} for this process's
|
|
79
|
+
* lifetime, is which action keys this listener has already put on the phone.
|
|
80
|
+
* Losing that memory (a restart, a crash) re-sends everything still pending:
|
|
81
|
+
* a duplicate on the phone, never silence. That direction is the whole design
|
|
82
|
+
* (SPEC.md §10.3: channels hold no state that is a source of truth).
|
|
83
|
+
*
|
|
84
|
+
* APRV-196 made that re-send legible rather than rarer. The first batch a
|
|
85
|
+
* process sends is preceded by one banner naming how many are coming, the
|
|
86
|
+
* copies already in the chat keep working (`actionRefOf` in
|
|
87
|
+
* `channels/telegram.ts` resolves their buttons to the same request), and the
|
|
88
|
+
* bookkeeping above is pruned as requests settle and age out instead of growing
|
|
89
|
+
* for the life of a listener that `approval up` keeps running for weeks.
|
|
90
|
+
*
|
|
91
|
+
* ### Send failures
|
|
92
|
+
*
|
|
93
|
+
* A key that fails to send stays undelivered, so the next cycle retries it.
|
|
94
|
+
* There is **no attempt limit**: giving up would turn a transient outage into a
|
|
95
|
+
* pending request no human ever sees, which is the one failure this project
|
|
96
|
+
* exists to prevent. The retry rate is bounded by the poll cycle itself (the
|
|
97
|
+
* long-poll timeout, or the channel's doubling backoff after a poll error), and
|
|
98
|
+
* the stderr warnings are throttled after {@link DISPATCH_LOUD_ATTEMPTS}
|
|
99
|
+
* consecutive failures for one key so a long outage cannot bury the terminal.
|
|
100
|
+
* The one exception is the **startup** dispatch, which still exits non-zero on
|
|
101
|
+
* a send failure: an operator who has just mistyped a chat id or a token should
|
|
102
|
+
* learn it immediately rather than watch a listener retry forever.
|
|
103
|
+
*/
|
|
104
|
+
import { readFileSync, statSync } from "node:fs";
|
|
105
|
+
import { isAbsolute, resolve as resolvePathSegments } from "node:path";
|
|
106
|
+
import { HUMAN_ACTOR_ENV, resolveHumanActor } from "../core/attest.js";
|
|
107
|
+
import { assembleBatch } from "../channels/batch.js";
|
|
108
|
+
import { recordChannelDecision, } from "../channels/contract.js";
|
|
109
|
+
import { ageText, buildPendingQueue, } from "../channels/tagging.js";
|
|
110
|
+
import { checkpointOfferFor, checkpointPromptLines, checkpointSignedLines, signCheckpointOffer, } from "./checkpoint-tap.js";
|
|
111
|
+
import { actionRefOf, decidedLine, groupForDigest, isMessageNotModified, isTelegramTerminalState, TelegramChannel, telegramChatEnvFor, telegramTokenEnvFor, TELEGRAM_NOT_RECORDED, TELEGRAM_REVIEW_DENIED, TELEGRAM_REVIEW_RECORDED, TELEGRAM_TERMINAL_HEADLINES, utcClock, } from "../channels/telegram.js";
|
|
112
|
+
import { openReviewCards } from "./audit-card.js";
|
|
113
|
+
import { reviewSample } from "../core/audit.js";
|
|
114
|
+
import { abandonedAfterMs, HOOK_DEFAULT_WAIT_MS, HOOK_RETRY_GRACE_MS, } from "../core/harness-wait.js";
|
|
115
|
+
import { telegramDeliveryFor } from "../core/telegram-config.js";
|
|
116
|
+
import { loadPolicy } from "../core/policy-load.js";
|
|
117
|
+
import { promptLayoutFor } from "../core/prompt-layout.js";
|
|
118
|
+
import { passphraseEnvFor } from "../core/vault.js";
|
|
119
|
+
import { isAttestationActionKey, proposalRecords, proposalState, } from "../core/policy-proposal.js";
|
|
120
|
+
import { payloadOf, readVerifiedRecords, requestState } from "../core/state.js";
|
|
121
|
+
import { boolFlag, parseFlags, stringFlag } from "./args.js";
|
|
122
|
+
import { GLOSS_TIMEOUT_MS } from "./gloss.js";
|
|
123
|
+
import { attachGloss, glossAbsenceLine } from "./gloss-attach.js";
|
|
124
|
+
import { glossRunnerFromOptions, parseGlossOptions, } from "./gloss-options.js";
|
|
125
|
+
import { EXIT_INTEGRITY, EXIT_IO, EXIT_OK, EXIT_USAGE } from "./exit-codes.js";
|
|
126
|
+
import { TELEGRAM_HEALTH_HELP, TELEGRAM_HELP, TELEGRAM_LISTEN_HELP, } from "./help.js";
|
|
127
|
+
import { DEFAULT_LOG_PATH, preflightLog, resolvePath } from "./paths.js";
|
|
128
|
+
import { style, tokenPanel, TOKEN_NOTICE_TELEGRAM } from "./style.js";
|
|
129
|
+
import { usageErrorText } from "./usage.js";
|
|
130
|
+
const LISTEN_FLAGS = {
|
|
131
|
+
"--log": "string",
|
|
132
|
+
"--policy": "string",
|
|
133
|
+
"--dir": "string",
|
|
134
|
+
"--as": "string",
|
|
135
|
+
"--payloads": "string",
|
|
136
|
+
"--api-base": "string",
|
|
137
|
+
"--poll-timeout": "string",
|
|
138
|
+
"--once": "boolean",
|
|
139
|
+
"--gloss": "boolean",
|
|
140
|
+
"--no-gloss": "boolean",
|
|
141
|
+
"--gloss-provider": "string",
|
|
142
|
+
"--gloss-model": "string",
|
|
143
|
+
"--json": "boolean",
|
|
144
|
+
"--help": "boolean",
|
|
145
|
+
"-h": "boolean",
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* The gloss runner this listener will use, as a spreadable fragment (APRV-197).
|
|
149
|
+
*
|
|
150
|
+
* ON unless `--no-gloss`. Two flags rather than one because the pair reads
|
|
151
|
+
* honestly next to `channel cli`, where the default is the other way round:
|
|
152
|
+
* `--gloss` is accepted here (and is simply the default restated) so that one
|
|
153
|
+
* command line works on both verbs, and `--no-gloss` wins a tie, because the
|
|
154
|
+
* flag that removes a language model from the path should never lose one.
|
|
155
|
+
*
|
|
156
|
+
* A fragment rather than a value so that "no runner" is the ABSENCE of the
|
|
157
|
+
* key. {@link ListenSetup.gloss} being optional is what lets every
|
|
158
|
+
* programmatic caller of `dispatchPending` spawn nothing without saying so.
|
|
159
|
+
*
|
|
160
|
+
* `passphraseEnv` is the name this policy's `vault.passphrase_env` gives, and
|
|
161
|
+
* the only thing the APRV-207 scrub needs from a policy: the subprocess is
|
|
162
|
+
* spawned starved either way, and naming the variable covers the deployment
|
|
163
|
+
* that renamed it out from under the credential prefixes.
|
|
164
|
+
*/
|
|
165
|
+
export function glossWiring(flags, passphraseEnv = null, factories = {}) {
|
|
166
|
+
const selected = parseGlossOptions(flags, true);
|
|
167
|
+
if (!selected.ok)
|
|
168
|
+
return {};
|
|
169
|
+
return glossWiringFor(selected.options, passphraseEnv, factories);
|
|
170
|
+
}
|
|
171
|
+
function glossWiringFor(selection, passphraseEnv, factories = {}) {
|
|
172
|
+
const gloss = glossRunnerFromOptions(selection, { ...factories, passphraseEnv });
|
|
173
|
+
return gloss === undefined ? {} : { gloss };
|
|
174
|
+
}
|
|
175
|
+
function usageError(streams, json, message, helpText) {
|
|
176
|
+
if (json)
|
|
177
|
+
streams.err(`${JSON.stringify({ error: { code: "usage", message } })}\n`);
|
|
178
|
+
else
|
|
179
|
+
streams.err(usageErrorText(message, helpText));
|
|
180
|
+
return EXIT_USAGE;
|
|
181
|
+
}
|
|
182
|
+
function ioError(streams, json, message) {
|
|
183
|
+
if (json)
|
|
184
|
+
streams.err(`${JSON.stringify({ error: { code: "io", message } })}\n`);
|
|
185
|
+
else
|
|
186
|
+
streams.err(`approval: ${message}\n`);
|
|
187
|
+
return EXIT_IO;
|
|
188
|
+
}
|
|
189
|
+
function integrityError(streams, json, message) {
|
|
190
|
+
if (json)
|
|
191
|
+
streams.err(`${JSON.stringify({ error: { code: "integrity", message } })}\n`);
|
|
192
|
+
else
|
|
193
|
+
streams.err(`approval: ${message}\n`);
|
|
194
|
+
return EXIT_INTEGRITY;
|
|
195
|
+
}
|
|
196
|
+
function absolute(value, cwd) {
|
|
197
|
+
return isAbsolute(value) ? value : resolvePathSegments(cwd, value);
|
|
198
|
+
}
|
|
199
|
+
/** A non-empty environment value, or `null`. Whitespace-only counts as unset. */
|
|
200
|
+
function env(name) {
|
|
201
|
+
const value = process.env[name];
|
|
202
|
+
return value === undefined || value.trim().length === 0 ? null : value.trim();
|
|
203
|
+
}
|
|
204
|
+
function payloadSource(resolved) {
|
|
205
|
+
if (resolved === null)
|
|
206
|
+
return { ok: true, source: undefined };
|
|
207
|
+
let parsed;
|
|
208
|
+
try {
|
|
209
|
+
parsed = JSON.parse(readFileSync(resolved, "utf8"));
|
|
210
|
+
}
|
|
211
|
+
catch (cause) {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
message: `--payloads ${resolved} could not be read as JSON: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
218
|
+
return {
|
|
219
|
+
ok: false,
|
|
220
|
+
message: `--payloads ${resolved} must hold a JSON object mapping action key to that action's payload value`,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const table = parsed;
|
|
224
|
+
return { ok: true, source: (actionKey) => table[actionKey] };
|
|
225
|
+
}
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Preparation, shared with the ambient runtime (APRV-110)
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
/**
|
|
230
|
+
* Why a listener could not be built. A closed union, because more than one
|
|
231
|
+
* caller now branches on it: the verb turns each into an exit code, and
|
|
232
|
+
* `approval up` turns each into a part it will not start (SPEC.md §11.1
|
|
233
|
+
* invariant 6 — a refusal is machine-readable and distinct).
|
|
234
|
+
*/
|
|
235
|
+
export const LISTEN_REFUSAL_CODES = [
|
|
236
|
+
/** A credential variable the policy names is unset or empty. */
|
|
237
|
+
"not-configured",
|
|
238
|
+
/** No `human:<id>` was declared, so nothing could be recorded against one. */
|
|
239
|
+
"no-identity",
|
|
240
|
+
/** `--poll-timeout` was not a whole number of seconds. */
|
|
241
|
+
"poll-timeout",
|
|
242
|
+
/** The log could not be read (or its directory does not exist). */
|
|
243
|
+
"log-unreadable",
|
|
244
|
+
/** `--payloads` did not hold a JSON object of action key -> payload. */
|
|
245
|
+
"payloads-unreadable",
|
|
246
|
+
];
|
|
247
|
+
/**
|
|
248
|
+
* Everything that can fail without touching the network, in order.
|
|
249
|
+
*
|
|
250
|
+
* Deliberately sequential and deliberately synchronous: an operator who typed
|
|
251
|
+
* the wrong thing learns it before a bot message is sent, and the async half
|
|
252
|
+
* below can then assume its configuration is whole.
|
|
253
|
+
*
|
|
254
|
+
* It PRINTS NOTHING and CHOOSES NO EXIT CODE (APRV-110). The verb below turns
|
|
255
|
+
* each refusal into the usage or I/O error it always was; `approval up` turns
|
|
256
|
+
* the same refusal into a channel it declines to start, reported in doctor's
|
|
257
|
+
* vocabulary while the other parts carry on. Two callers, one set of checks,
|
|
258
|
+
* one set of sentences — which is the only way the two surfaces can agree about
|
|
259
|
+
* what "telegram is not configured" means.
|
|
260
|
+
*/
|
|
261
|
+
export function prepareListen(request) {
|
|
262
|
+
// Configuration is environment-only: policy names the variables, never the
|
|
263
|
+
// values (SPEC.md §5.1), and there is no flag that would put a bot token in
|
|
264
|
+
// a shell history or a process listing. A policy that fails to load names
|
|
265
|
+
// nothing and the reference defaults apply — the load is fail-closed for
|
|
266
|
+
// autonomy and budgets, and a variable name is not a permission.
|
|
267
|
+
const policyLoad = loadPolicy(request.policy);
|
|
268
|
+
const tokenEnv = telegramTokenEnvFor(policyLoad);
|
|
269
|
+
const chatEnv = telegramChatEnvFor(policyLoad);
|
|
270
|
+
const token = env(tokenEnv);
|
|
271
|
+
const chatId = env(chatEnv);
|
|
272
|
+
if (token === null || chatId === null) {
|
|
273
|
+
const missing = [
|
|
274
|
+
token === null ? tokenEnv : null,
|
|
275
|
+
chatId === null ? chatEnv : null,
|
|
276
|
+
].filter((name) => name !== null);
|
|
277
|
+
return {
|
|
278
|
+
ok: false,
|
|
279
|
+
code: "not-configured",
|
|
280
|
+
message: `telegram is not configured: ${missing.join(" and ")} ${missing.length === 1 ? "is" : "are"} unset or empty (both ${tokenEnv} and ${chatEnv} are required; APPROVAL.md carries only their names)`,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const actor = resolveHumanActor(request.as === null ? {} : { actor: request.as });
|
|
284
|
+
if (actor === null) {
|
|
285
|
+
return {
|
|
286
|
+
ok: false,
|
|
287
|
+
code: "no-identity",
|
|
288
|
+
message: request.as === null
|
|
289
|
+
? `no human identity: set ${HUMAN_ACTOR_ENV}=human:<id> or pass --as human:<id>. Every decision this listener records is recorded against it, and nothing here authenticates it`
|
|
290
|
+
: `--as expects a human identity matching human:<id>, got ${JSON.stringify(request.as)}; approvals are human-only`,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const pollFlag = request.pollTimeout;
|
|
294
|
+
if (pollFlag !== null && !/^\d+$/u.test(pollFlag)) {
|
|
295
|
+
return {
|
|
296
|
+
ok: false,
|
|
297
|
+
code: "poll-timeout",
|
|
298
|
+
message: `--poll-timeout expects a whole number of seconds, got ${JSON.stringify(pollFlag)}`,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const check = preflightLog(request.logPath);
|
|
302
|
+
if (!check.ok)
|
|
303
|
+
return { ok: false, code: "log-unreadable", message: check.message };
|
|
304
|
+
const payloads = payloadSource(request.payloads);
|
|
305
|
+
if (!payloads.ok) {
|
|
306
|
+
return { ok: false, code: "payloads-unreadable", message: payloads.message };
|
|
307
|
+
}
|
|
308
|
+
const config = {
|
|
309
|
+
token,
|
|
310
|
+
chatId,
|
|
311
|
+
...(request.apiBase === null ? {} : { apiBase: request.apiBase }),
|
|
312
|
+
...(pollFlag === null ? {} : { pollTimeoutSeconds: Number.parseInt(pollFlag, 10) }),
|
|
313
|
+
log: request.log,
|
|
314
|
+
// APRV-135. The policy is already loaded above for the variable names; the
|
|
315
|
+
// TTL rides along so the listener can forget delivery bookkeeping no
|
|
316
|
+
// callback can still be honoured against. The channel reads no policy file
|
|
317
|
+
// of its own, and a policy that failed to load declares no TTL, which makes
|
|
318
|
+
// the sweep narrower rather than wider.
|
|
319
|
+
approvalTtlMs: policyLoad.ok ? policyLoad.durations.approvalTtlMs : null,
|
|
320
|
+
// APRV-196. The channel answers a tap on a copy it is not holding open by
|
|
321
|
+
// asking what the LOG says, which is the only thing that knows. Wired here
|
|
322
|
+
// because the log path lives here and nothing under `channels/` reads one.
|
|
323
|
+
describeAction: describeActionFor(request.logPath),
|
|
324
|
+
// APRV-218. Which rows the prompt shows, from `channels.telegram.prompt`,
|
|
325
|
+
// off the same load the credential NAMES and the TTL came from: one read of
|
|
326
|
+
// the policy file answers every question this preparation asks of it. A
|
|
327
|
+
// policy that failed to load declares no layout and gets the slimmed
|
|
328
|
+
// default, because a layout is not a permission and an unrelated typo in a
|
|
329
|
+
// class rule must not silently redecorate a phone screen.
|
|
330
|
+
layout: promptLayoutFor(policyLoad, "telegram"),
|
|
331
|
+
};
|
|
332
|
+
return {
|
|
333
|
+
ok: true,
|
|
334
|
+
setup: {
|
|
335
|
+
channel: new TelegramChannel(config),
|
|
336
|
+
logPath: request.logPath,
|
|
337
|
+
actor,
|
|
338
|
+
json: request.json,
|
|
339
|
+
once: request.once,
|
|
340
|
+
// APRV-216. Read from the policy that was already loaded above for the
|
|
341
|
+
// credential NAMES, so one load answers every question this preparation
|
|
342
|
+
// asks of the policy file, and a policy that failed to load leaves the
|
|
343
|
+
// default (paced) in force rather than a mode nobody chose.
|
|
344
|
+
delivery: telegramDeliveryFor(policyLoad),
|
|
345
|
+
gateOptions: { policy: request.policy },
|
|
346
|
+
tagOptions: {
|
|
347
|
+
policy: request.policy,
|
|
348
|
+
...(payloads.source === undefined ? {} : { payload: payloads.source }),
|
|
349
|
+
},
|
|
350
|
+
...(request.gloss === undefined ? {} : { gloss: request.gloss }),
|
|
351
|
+
// APRV-257. The tap's whole configuration: the log to sign, the policy to
|
|
352
|
+
// read the cadence and the keys from, and where the private half may come
|
|
353
|
+
// from — which is the vault beside this log unless an operator said
|
|
354
|
+
// otherwise. No key is read here: custody is resolved at TAP time, so a
|
|
355
|
+
// prompt sitting on a phone holds no key material anywhere.
|
|
356
|
+
checkpoint: {
|
|
357
|
+
logPath: request.logPath,
|
|
358
|
+
policy: request.policy,
|
|
359
|
+
keyFile: null,
|
|
360
|
+
vault: null,
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* What to tell a human who tapped a button for an action this listener is not
|
|
367
|
+
* holding open (APRV-196).
|
|
368
|
+
*
|
|
369
|
+
* The one place a stale tap gets a real answer instead of a shrug. It reads the
|
|
370
|
+
* VERIFIED log (SPEC.md §11.1(1): a sentence a human reads about what the log
|
|
371
|
+
* says is derived from a log that verified, or it is not derived at all) and
|
|
372
|
+
* answers from `requestState`, the same derivation the gate and the pending
|
|
373
|
+
* queue use. Nothing here decides anything, nothing is appended, and nothing is
|
|
374
|
+
* remembered between calls: an unreadable log answers `null`, which the channel
|
|
375
|
+
* renders as its "not open here" toast.
|
|
376
|
+
*
|
|
377
|
+
* The argument is an action REFERENCE and never a key. The string came off the
|
|
378
|
+
* network, so this hashes the keys the log actually carries and looks for a
|
|
379
|
+
* match; a caller cannot make it describe a request by naming one, and a ref
|
|
380
|
+
* matching nothing simply answers `null`.
|
|
381
|
+
*
|
|
382
|
+
* The walk is over `approval.requested` records, which is the set of things
|
|
383
|
+
* that could ever have had a button. Run only on a stale tap, which is rare by
|
|
384
|
+
* construction.
|
|
385
|
+
*/
|
|
386
|
+
export function describeActionFor(logPath) {
|
|
387
|
+
return (actionRef) => {
|
|
388
|
+
const read = readVerifiedRecords(logPath);
|
|
389
|
+
if (!read.ok)
|
|
390
|
+
return null;
|
|
391
|
+
const now = new Date().toISOString();
|
|
392
|
+
for (const record of read.records) {
|
|
393
|
+
if (record.event !== "approval.requested")
|
|
394
|
+
continue;
|
|
395
|
+
const key = record.action_key ?? payloadOf(record)["action_key"];
|
|
396
|
+
if (typeof key !== "string" || actionRefOf(key) !== actionRef)
|
|
397
|
+
continue;
|
|
398
|
+
const derived = requestState(read.records, key, now, null);
|
|
399
|
+
switch (derived.state) {
|
|
400
|
+
case "granted":
|
|
401
|
+
case "rejected":
|
|
402
|
+
case "revoked":
|
|
403
|
+
return `Already ${derived.state} — the recorded answer stands, and nothing was recorded for this tap.`;
|
|
404
|
+
case "expired":
|
|
405
|
+
return "Expired — the approval window closed before an answer arrived; nothing was recorded.";
|
|
406
|
+
case "withdrawn":
|
|
407
|
+
return "Withdrawn — the requester took this back and is no longer waiting; nothing was recorded.";
|
|
408
|
+
case "requested":
|
|
409
|
+
return "Still pending — this copy's buttons are not live here. Tap the newest copy of this request in this chat.";
|
|
410
|
+
default:
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
return null;
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
/** The verb's own front matter: flags in, a prepared listener or an exit code out. */
|
|
418
|
+
function setUp(argv, streams, cwd) {
|
|
419
|
+
const json = argv.includes("--json");
|
|
420
|
+
const parsed = parseFlags(argv, LISTEN_FLAGS);
|
|
421
|
+
if (!parsed.ok) {
|
|
422
|
+
return { kind: "handled", code: usageError(streams, json, parsed.message, TELEGRAM_LISTEN_HELP) };
|
|
423
|
+
}
|
|
424
|
+
if (boolFlag(parsed.flags, "--help") || boolFlag(parsed.flags, "-h")) {
|
|
425
|
+
streams.out(`${TELEGRAM_LISTEN_HELP}\n`);
|
|
426
|
+
return { kind: "handled", code: EXIT_OK };
|
|
427
|
+
}
|
|
428
|
+
const extra = parsed.positionals[0];
|
|
429
|
+
if (extra !== undefined) {
|
|
430
|
+
return {
|
|
431
|
+
kind: "handled",
|
|
432
|
+
code: usageError(streams, json, `unexpected argument ${JSON.stringify(extra)}`, TELEGRAM_LISTEN_HELP),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
const flags = parsed.flags;
|
|
436
|
+
const selectedGloss = parseGlossOptions(flags, true);
|
|
437
|
+
if (!selectedGloss.ok) {
|
|
438
|
+
return {
|
|
439
|
+
kind: "handled",
|
|
440
|
+
code: usageError(streams, json, selectedGloss.message, TELEGRAM_LISTEN_HELP),
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
// Resolved here rather than after the log preflight because the policy is
|
|
444
|
+
// what NAMES the credential variables, and a message about a missing
|
|
445
|
+
// variable must name the one this policy actually asked for.
|
|
446
|
+
const policyFlag = stringFlag(flags, "--policy");
|
|
447
|
+
const dirFlag = stringFlag(flags, "--dir");
|
|
448
|
+
const policy = policyFlag !== null
|
|
449
|
+
? { file: absolute(policyFlag, cwd) }
|
|
450
|
+
: { dir: dirFlag === null ? cwd : absolute(dirFlag, cwd) };
|
|
451
|
+
const payloadsFlag = stringFlag(flags, "--payloads");
|
|
452
|
+
const prepared = prepareListen({
|
|
453
|
+
logPath: resolvePath(stringFlag(flags, "--log"), DEFAULT_LOG_PATH, cwd),
|
|
454
|
+
policy,
|
|
455
|
+
as: stringFlag(flags, "--as"),
|
|
456
|
+
payloads: payloadsFlag === null ? null : absolute(payloadsFlag, cwd),
|
|
457
|
+
apiBase: stringFlag(flags, "--api-base"),
|
|
458
|
+
pollTimeout: stringFlag(flags, "--poll-timeout"),
|
|
459
|
+
once: boolFlag(flags, "--once"),
|
|
460
|
+
json,
|
|
461
|
+
log: (message) => streams.err(`${message}\n`),
|
|
462
|
+
// APRV-144, on by default, `--no-gloss` to turn it off (APRV-197).
|
|
463
|
+
//
|
|
464
|
+
// The listener is the surface the gloss was asked for: the phone is where
|
|
465
|
+
// an approver reads a request they did not watch being made, with none of
|
|
466
|
+
// the terminal's context around it. The measured 10-15 seconds a gloss
|
|
467
|
+
// costs (see GLOSS_TIMEOUT_MS) is spent inside a dispatch cycle that is
|
|
468
|
+
// already waiting on the network, and it blocks nobody — which is exactly
|
|
469
|
+
// why the terminal walker makes the opposite choice and asks only under
|
|
470
|
+
// `--gloss`: there, a person is sitting in front of the pause.
|
|
471
|
+
//
|
|
472
|
+
// The verb is still the only place a runner is wired: `dispatchPending`
|
|
473
|
+
// defaults to none, so no programmatic driver spawns a subprocess by
|
|
474
|
+
// importing it. Tests that drive THIS function pass `--no-gloss` or set a
|
|
475
|
+
// stub, which is what {@link listenGlossRunner} is for.
|
|
476
|
+
...glossWiringFor(selectedGloss.options, passphraseEnvFor(loadPolicy(policy)), {
|
|
477
|
+
diagnostic: (reason) => streams.err(`approval: Codex gloss unavailable (${reason}); continuing without it\n`),
|
|
478
|
+
}),
|
|
479
|
+
});
|
|
480
|
+
if (!prepared.ok) {
|
|
481
|
+
// The mapping the verb has always used: a mistyped command line or a
|
|
482
|
+
// missing variable is usage; a path that could not be read is I/O.
|
|
483
|
+
const code = prepared.code === "log-unreadable" || prepared.code === "payloads-unreadable"
|
|
484
|
+
? ioError(streams, json, prepared.message)
|
|
485
|
+
: usageError(streams, json, prepared.message, TELEGRAM_LISTEN_HELP);
|
|
486
|
+
return { kind: "handled", code };
|
|
487
|
+
}
|
|
488
|
+
return { kind: "run", setup: prepared.setup };
|
|
489
|
+
}
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
// Dispatch (APRV-55) — one cycle's worth of "put pending requests on the phone"
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
/**
|
|
494
|
+
* Consecutive failures for one action key after which stderr warnings thin out.
|
|
495
|
+
*
|
|
496
|
+
* Not an attempt limit: the send is retried on every cycle forever (see the
|
|
497
|
+
* module doc). Only the complaining is throttled, to every tenth attempt.
|
|
498
|
+
*/
|
|
499
|
+
export const DISPATCH_LOUD_ATTEMPTS = 3;
|
|
500
|
+
/**
|
|
501
|
+
* How long an unannotated delivery stays in the bookkeeping before it is
|
|
502
|
+
* dropped (APRV-196). Twenty-four hours, matching the channel's own
|
|
503
|
+
* `TELEGRAM_DEFAULT_RETENTION_MS`.
|
|
504
|
+
*
|
|
505
|
+
* It is a floor on forgetting and not a deadline for anything: a request that
|
|
506
|
+
* is still pending is never dropped however old it is, because the pending
|
|
507
|
+
* queue is checked first. What this bounds is the memory a long-lived listener
|
|
508
|
+
* holds for questions the log has finished with.
|
|
509
|
+
*/
|
|
510
|
+
export const DISPATCH_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
511
|
+
/**
|
|
512
|
+
* The line that introduces the first batch a listener process sends (APRV-196).
|
|
513
|
+
*
|
|
514
|
+
* **Why a banner and not an edit of the earlier copies.** The incident was a
|
|
515
|
+
* restart re-sending five pending requests with no warning, on top of five
|
|
516
|
+
* copies whose buttons had quietly stopped working. Editing those earlier
|
|
517
|
+
* copies to say "superseded" would read better — and it is not a design that
|
|
518
|
+
* can be relied on, because it requires this process to know their message ids,
|
|
519
|
+
* which a restart by definition does not: SPEC.md §10.3 forbids channel state
|
|
520
|
+
* that is a source of truth, and a crash loses a cache whether or not one is
|
|
521
|
+
* allowed. A design that only works when the crash was gentle is a design that
|
|
522
|
+
* fails on the day it is needed. So the banner is unconditional, and the
|
|
523
|
+
* earlier copies are made harmless instead of tidy: their buttons resolve by
|
|
524
|
+
* action reference to the request this process has just re-delivered
|
|
525
|
+
* (`actionRefOf` in `channels/telegram.ts`), so a human who taps the copy they
|
|
526
|
+
* can see decides the request they meant.
|
|
527
|
+
*
|
|
528
|
+
* It says "started" rather than "restarted" because a listener cannot tell the
|
|
529
|
+
* two apart, having deliberately kept nothing that would let it, and a first
|
|
530
|
+
* start that claimed to be a restart would be this channel's own text lying
|
|
531
|
+
* about the system's history.
|
|
532
|
+
*/
|
|
533
|
+
export function bannerLines(pending) {
|
|
534
|
+
const plural = pending === 1 ? "request" : "requests";
|
|
535
|
+
return [
|
|
536
|
+
`LISTENER STARTED — re-sending ${pending} pending ${plural}.`,
|
|
537
|
+
`The ${pending === 1 ? "message" : "messages"} below ${pending === 1 ? "is" : "are"} the live ${pending === 1 ? "copy" : "copies"}. If an earlier copy of the same request is further up this chat, its buttons still decide the same request; nothing is decided twice.`,
|
|
538
|
+
"Which requests are pending is read from the log on every cycle, never from this chat.",
|
|
539
|
+
];
|
|
540
|
+
}
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
// Paced delivery (APRV-216) — one question at a time
|
|
543
|
+
// ---------------------------------------------------------------------------
|
|
544
|
+
/**
|
|
545
|
+
* The summary line that precedes a paced send, and the body of `/queue`.
|
|
546
|
+
*
|
|
547
|
+
* One message, and everything in it is arithmetic on the verified log at the
|
|
548
|
+
* instant it is written: how many requests are pending, how long the oldest has
|
|
549
|
+
* waited, and which classes they are. Nothing is remembered between calls, so
|
|
550
|
+
* two summaries a minute apart can disagree only because the log moved.
|
|
551
|
+
*
|
|
552
|
+
* The class tally is the part worth the space. The count alone says how much
|
|
553
|
+
* work is waiting; the classes say what KIND of work, which is what tells an
|
|
554
|
+
* approver whether the queue is six identical `network.call`s they can walk
|
|
555
|
+
* through or one `policy.edit` they should read carefully.
|
|
556
|
+
*/
|
|
557
|
+
export function summaryLines(requests, now) {
|
|
558
|
+
if (requests.length === 0)
|
|
559
|
+
return ["Nothing pending — the queue is empty."];
|
|
560
|
+
const nowMs = Date.parse(now);
|
|
561
|
+
const oldest = requests[0];
|
|
562
|
+
const oldestMs = Date.parse(oldest.requested_ts.value);
|
|
563
|
+
const age = Number.isNaN(nowMs) || Number.isNaN(oldestMs) ? "unknown age" : ageText(nowMs - oldestMs);
|
|
564
|
+
const tally = new Map();
|
|
565
|
+
for (const request of requests) {
|
|
566
|
+
const cls = request.class.value;
|
|
567
|
+
tally.set(cls, (tally.get(cls) ?? 0) + 1);
|
|
568
|
+
}
|
|
569
|
+
const classes = [...tally.entries()]
|
|
570
|
+
.map(([cls, count]) => (count === 1 ? cls : `${cls} ×${String(count)}`))
|
|
571
|
+
.join(", ");
|
|
572
|
+
return [
|
|
573
|
+
`${String(requests.length)} pending — oldest ${age} — ${classes}`,
|
|
574
|
+
];
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* The marker `/queue` puts on the request this listener has selected (APRV-256).
|
|
578
|
+
*
|
|
579
|
+
* It says two things and claims no third. "Selected" is this process's memory of
|
|
580
|
+
* which request it is holding; "card sent earlier" is the delivery bookkeeping
|
|
581
|
+
* recording that a send once succeeded. Neither is evidence that the card is
|
|
582
|
+
* still in the chat: Telegram reports a successful send, never a message's
|
|
583
|
+
* continued existence, and a card can be deleted, buried under a thousand later
|
|
584
|
+
* messages, or lost with the chat history on a reinstall. The old marker,
|
|
585
|
+
* "shown now", asserted present visibility from a past delivery, which is the
|
|
586
|
+
* bug this constant exists to keep fixed.
|
|
587
|
+
*/
|
|
588
|
+
const SELECTED_MARKER = " — selected — card sent earlier";
|
|
589
|
+
/**
|
|
590
|
+
* What `/queue` says about itself, immediately under the summary (APRV-256).
|
|
591
|
+
*
|
|
592
|
+
* `/queue` is a summary reply and carries no buttons, so an approver reading it
|
|
593
|
+
* on a phone must not be left hunting this message for controls that were never
|
|
594
|
+
* on it. Where the controls DO live is stated without a direction: a card is
|
|
595
|
+
* somewhere in the chat's history, and "above" was only ever true for the
|
|
596
|
+
* approver who asked while looking straight at it.
|
|
597
|
+
*/
|
|
598
|
+
const QUEUE_IS_A_LIST = "This is a list of what the log is holding. It has no decision buttons: a request is decided on its own approval card, wherever that card sits in this chat.";
|
|
599
|
+
/**
|
|
600
|
+
* `/queue`'s reply: the summary, then one numbered line per pending request.
|
|
601
|
+
*
|
|
602
|
+
* Derived, like the summary, from the verified log at reply time and not from
|
|
603
|
+
* anything this process is holding: the numbering is positional and names no
|
|
604
|
+
* button, so a stale copy of this list cannot be used to decide anything. The
|
|
605
|
+
* marker says which one this listener has selected and once delivered, because
|
|
606
|
+
* the question `/queue` is usually asked to answer is "what else is there
|
|
607
|
+
* besides the one I am looking at" — and, since APRV-256, its unhappy twin,
|
|
608
|
+
* "where is the one I am supposed to be looking at".
|
|
609
|
+
*
|
|
610
|
+
* The footer answers that second question the only honest way available to a
|
|
611
|
+
* process whose knowledge of the chat ends at "a send returned success": it
|
|
612
|
+
* says what was sent, says it cannot tell whether the card survived, and then
|
|
613
|
+
* spends its remaining words on recovery rather than reassurance.
|
|
614
|
+
*/
|
|
615
|
+
export function queueLines(requests, now, shown) {
|
|
616
|
+
const lines = summaryLines(requests, now);
|
|
617
|
+
if (requests.length === 0)
|
|
618
|
+
return lines;
|
|
619
|
+
const nowMs = Date.parse(now);
|
|
620
|
+
const current = new Set(shown);
|
|
621
|
+
let selected = 0;
|
|
622
|
+
lines.push(QUEUE_IS_A_LIST);
|
|
623
|
+
requests.forEach((request, index) => {
|
|
624
|
+
const key = request.action_key.value;
|
|
625
|
+
const requestedMs = Date.parse(request.requested_ts.value);
|
|
626
|
+
const age = Number.isNaN(nowMs) || Number.isNaN(requestedMs)
|
|
627
|
+
? "unknown age"
|
|
628
|
+
: ageText(nowMs - requestedMs);
|
|
629
|
+
const task = request.task.value ?? "no task";
|
|
630
|
+
const isSelected = current.has(key);
|
|
631
|
+
if (isSelected)
|
|
632
|
+
selected += 1;
|
|
633
|
+
lines.push(`${String(index + 1)}. ${key} — ${task} — ${request.class.value} — ${age}${isSelected ? SELECTED_MARKER : ""}`);
|
|
634
|
+
});
|
|
635
|
+
if (selected === 0) {
|
|
636
|
+
// Nothing selected is an ordinary state, not a fault: a decided or passed
|
|
637
|
+
// over request leaves the listener holding nothing until the next cycle
|
|
638
|
+
// picks the next one up. Saying so is what stops the reader searching the
|
|
639
|
+
// chat for a card this process never claimed to have sent.
|
|
640
|
+
lines.push("Nothing is selected right now, so no approval card has been sent for any of these. The next one goes out with its buttons on an upcoming listener cycle.");
|
|
641
|
+
return lines;
|
|
642
|
+
}
|
|
643
|
+
// More than one key is marked when the selection is a digest group, which
|
|
644
|
+
// Telegram receives as ONE card covering the set. Hence "a single approval
|
|
645
|
+
// card" in the plural branch and no positional word in either: the reply may
|
|
646
|
+
// be chunked across several messages, so "above" is not this function's to
|
|
647
|
+
// promise even about its own lines.
|
|
648
|
+
const holding = selected === 1
|
|
649
|
+
? "The request marked selected is the one this listener is holding, and an approval card for it was sent to this chat earlier. The buttons on that card decide it."
|
|
650
|
+
: `The ${String(selected)} requests marked selected are what this listener is holding as one digest, and a single approval card for them was sent to this chat earlier. The buttons on that card decide them.`;
|
|
651
|
+
lines.push(`${holding} This listener cannot tell whether that card is still here.`, "If you cannot find the card, /skip is the recovery: it puts the request at the back of the order and lets the next one through. Nothing is decided by typing it, the request stays pending in the log, and a fresh card is sent on a later listener cycle once the requests ahead of it have had their turn (a cycle can run a little long while a gloss is being written).", "/next gives up your place instead: this listener moves past the request and stops offering it, and no new card is sent for it. It is not a way to ask for the card again.");
|
|
652
|
+
return lines;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* The summary line that precedes a review card, and `/queue`'s review footer.
|
|
656
|
+
*
|
|
657
|
+
* Arithmetic on the verified log at the instant it is written, exactly as
|
|
658
|
+
* {@link summaryLines} is: how many samples are awaiting review, how old the
|
|
659
|
+
* oldest is, and which classes they are. The last clause is the one that stops
|
|
660
|
+
* a reader treating this like the pending queue: nothing here is waiting on
|
|
661
|
+
* them, because all of it has already happened.
|
|
662
|
+
*/
|
|
663
|
+
export function reviewSummaryLines(cards, now) {
|
|
664
|
+
if (cards.length === 0)
|
|
665
|
+
return ["Nothing awaiting review."];
|
|
666
|
+
const nowMs = Date.parse(now);
|
|
667
|
+
const ages = cards
|
|
668
|
+
.map((card) => nowMs - Date.parse(card.ranAtTs))
|
|
669
|
+
.filter((age) => !Number.isNaN(age) && age >= 0);
|
|
670
|
+
const oldest = ages.length === 0 ? null : Math.max(...ages);
|
|
671
|
+
const tally = new Map();
|
|
672
|
+
for (const card of cards) {
|
|
673
|
+
const cls = card.fields.class.value;
|
|
674
|
+
tally.set(cls, (tally.get(cls) ?? 0) + 1);
|
|
675
|
+
}
|
|
676
|
+
const classes = [...tally.entries()]
|
|
677
|
+
.map(([cls, count]) => (count === 1 ? cls : `${cls} ×${String(count)}`))
|
|
678
|
+
.join(", ");
|
|
679
|
+
return [
|
|
680
|
+
`${String(cards.length)} awaiting review — oldest ran ${oldest === null ? "at an unknown time" : ageText(oldest)} — ${classes}`,
|
|
681
|
+
"These already ran. Nothing is waiting on you and no card here authorizes anything; a review records what a person thought of work that is already done.",
|
|
682
|
+
];
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Note when a key was delivered, for the retention sweep (APRV-196).
|
|
686
|
+
*
|
|
687
|
+
* The cycle's own `now` rather than a clock read, for the reason every other
|
|
688
|
+
* instant in this file is a parameter: the tests drive dispatch at chosen
|
|
689
|
+
* instants, and a sweep judged against `Date.now()` would be untestable and
|
|
690
|
+
* would disagree with the TTL arithmetic beside it.
|
|
691
|
+
*/
|
|
692
|
+
function remember(state, actionKey, now) {
|
|
693
|
+
const ms = Date.parse(now);
|
|
694
|
+
if (!Number.isNaN(ms))
|
|
695
|
+
state.sentAtMs.set(actionKey, ms);
|
|
696
|
+
}
|
|
697
|
+
/** Drop every trace of one action key from the bookkeeping (APRV-196). */
|
|
698
|
+
function forget(state, actionKey) {
|
|
699
|
+
state.delivered.delete(actionKey);
|
|
700
|
+
state.sentAtMs.delete(actionKey);
|
|
701
|
+
state.attempts.delete(actionKey);
|
|
702
|
+
state.annotated.delete(actionKey);
|
|
703
|
+
for (const token of state.warned) {
|
|
704
|
+
if (token.startsWith(`${actionKey}:`))
|
|
705
|
+
state.warned.delete(token);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
export function newDispatchState() {
|
|
709
|
+
return {
|
|
710
|
+
delivered: new Map(),
|
|
711
|
+
sentAtMs: new Map(),
|
|
712
|
+
banner: { sent: false },
|
|
713
|
+
attempts: new Map(),
|
|
714
|
+
warned: new Set(),
|
|
715
|
+
annotated: new Set(),
|
|
716
|
+
paced: { order: [], current: null, summarySent: false, announced: 0 },
|
|
717
|
+
checkpoint: { offered: false, offeredSince: null },
|
|
718
|
+
review: {
|
|
719
|
+
order: [],
|
|
720
|
+
current: null,
|
|
721
|
+
delivered: new Map(),
|
|
722
|
+
summarySent: false,
|
|
723
|
+
announced: 0,
|
|
724
|
+
logSize: null,
|
|
725
|
+
},
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
/** The event that records each terminal state, for finding the settling record. */
|
|
729
|
+
const TERMINAL_EVENT = {
|
|
730
|
+
granted: "approval.granted",
|
|
731
|
+
rejected: "approval.rejected",
|
|
732
|
+
revoked: "approval.revoked",
|
|
733
|
+
expired: "approval.expired",
|
|
734
|
+
withdrawn: "approval.withdrawn",
|
|
735
|
+
};
|
|
736
|
+
/**
|
|
737
|
+
* Which of this process's deliveries the log says are settled (APRV-113,
|
|
738
|
+
* generalizing APRV-106's withdrawal-only pass).
|
|
739
|
+
*
|
|
740
|
+
* This is the cross-surface half of the feature. A request answered at the CLI
|
|
741
|
+
* or on the web queue, revoked afterwards, or expired by the daemon, leaves a
|
|
742
|
+
* chat prompt that this process delivered and that nothing else will ever
|
|
743
|
+
* correct — so every cycle asks the verified log what became of each message it
|
|
744
|
+
* sent, and annotates the ones that are over.
|
|
745
|
+
*
|
|
746
|
+
* Reads only VERIFIED records (SPEC.md §11.1(1)). A channel edit is not an
|
|
747
|
+
* enforcement decision, but it is a statement to a human about what the log
|
|
748
|
+
* says, and reading the log unverified to make one would be the same defect in
|
|
749
|
+
* a smaller hat.
|
|
750
|
+
*
|
|
751
|
+
* Never throws: an unreadable or unverifiable log yields an empty list, and the
|
|
752
|
+
* cycle's own `queueError` path already reports that failure. Annotating from a
|
|
753
|
+
* log this process could not verify would be worse than leaving the message.
|
|
754
|
+
*/
|
|
755
|
+
function terminalDeliveries(setup, state, now) {
|
|
756
|
+
if (state.delivered.size === 0)
|
|
757
|
+
return [];
|
|
758
|
+
const read = readVerifiedRecords(setup.logPath);
|
|
759
|
+
if (!read.ok)
|
|
760
|
+
return [];
|
|
761
|
+
const settled = [];
|
|
762
|
+
for (const [actionKey, deliveryId] of state.delivered) {
|
|
763
|
+
// APRV-109. An attestation prompt has no `approval.requested` behind it, so
|
|
764
|
+
// `requestState` can say nothing about it and the message would stay armed
|
|
765
|
+
// forever — the exact stale prompt APRV-106 added this pass to retire. Its
|
|
766
|
+
// own derivation answers instead, and the four terminal proposal states map
|
|
767
|
+
// onto headlines this channel already has: an attested proposal reads
|
|
768
|
+
// `granted`, a declined one `rejected`, a superseded one `withdrawn`
|
|
769
|
+
// (a newer proposal is the live question now), and a lapsed one `expired`.
|
|
770
|
+
if (isAttestationActionKey(actionKey)) {
|
|
771
|
+
const proposal = proposalRecords(read.records).find((entry) => entry.action_key === actionKey);
|
|
772
|
+
const derived = proposal === undefined ? null : proposalState(read.records, proposal.seq, now);
|
|
773
|
+
if (derived === null || derived.state === "open")
|
|
774
|
+
continue;
|
|
775
|
+
const outcome = derived.state === "attested"
|
|
776
|
+
? "granted"
|
|
777
|
+
: derived.state === "declined"
|
|
778
|
+
? "rejected"
|
|
779
|
+
: derived.state === "superseded"
|
|
780
|
+
? "withdrawn"
|
|
781
|
+
: "expired";
|
|
782
|
+
const answer = read.records.find((entry) => entry.seq > derived.seq &&
|
|
783
|
+
(entry.event === "policy.updated" || entry.event === "policy.declined") &&
|
|
784
|
+
payloadOf(entry)["sha256"] === derived.sha256);
|
|
785
|
+
settled.push({
|
|
786
|
+
actionKey,
|
|
787
|
+
deliveryId,
|
|
788
|
+
outcome,
|
|
789
|
+
detail: derived.state === "superseded"
|
|
790
|
+
? [`a later amendment of the same policy replaced this prompt · nothing to do`]
|
|
791
|
+
: derived.state === "expired"
|
|
792
|
+
? [`no answer arrived before the proposer's deadline · nothing was attested`]
|
|
793
|
+
: answer === undefined
|
|
794
|
+
? [`recorded at ${utcClock(now)}`]
|
|
795
|
+
: [decidedLine(answer.actor, answer.ts, answer.seq)],
|
|
796
|
+
});
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
// `ttlMs: null` is correct here rather than lazy, and it is the reason this
|
|
800
|
+
// pass annotates the daemon's `approval.expired` but not a TTL that has
|
|
801
|
+
// merely lapsed by arithmetic: an annotation states what the LOG says, and
|
|
802
|
+
// a lazily-expired request has no record saying anything yet. Loading the
|
|
803
|
+
// policy to compute a deadline would also make a cosmetic edit depend on a
|
|
804
|
+
// file read that can fail. The armed message left behind is refused at the
|
|
805
|
+
// gate, and gets its annotation on the cycle after the daemon writes.
|
|
806
|
+
const derivation = requestState(read.records, actionKey, now, null);
|
|
807
|
+
if (!isTelegramTerminalState(derivation.state))
|
|
808
|
+
continue;
|
|
809
|
+
const outcome = derivation.state;
|
|
810
|
+
const record = read.records.find((entry) => entry.seq === derivation.decisionSeq && entry.event === TERMINAL_EVENT[outcome]);
|
|
811
|
+
const payload = record === undefined ? {} : payloadOf(record);
|
|
812
|
+
const at = record === undefined ? now : record.ts;
|
|
813
|
+
const seq = record?.seq ?? derivation.decisionSeq;
|
|
814
|
+
if (outcome === "withdrawn") {
|
|
815
|
+
const why = typeof payload["reason"] === "string" ? payload["reason"] : "withdrawn";
|
|
816
|
+
const note = typeof payload["note"] === "string" ? `\n${payload["note"]}` : "";
|
|
817
|
+
settled.push({
|
|
818
|
+
actionKey,
|
|
819
|
+
deliveryId,
|
|
820
|
+
outcome,
|
|
821
|
+
// APRV-106's exact line, unchanged: it is what the approver reads.
|
|
822
|
+
detail: [`withdrawn by the requester at ${utcClock(at)} (${why}) · nothing to do${note}`],
|
|
823
|
+
});
|
|
824
|
+
continue;
|
|
825
|
+
}
|
|
826
|
+
if (outcome === "expired") {
|
|
827
|
+
settled.push({
|
|
828
|
+
actionKey,
|
|
829
|
+
deliveryId,
|
|
830
|
+
outcome,
|
|
831
|
+
detail: [
|
|
832
|
+
`no answer arrived before the deadline · recorded at ${utcClock(at)}${seq === null ? "" : ` (seq ${seq})`}`,
|
|
833
|
+
],
|
|
834
|
+
});
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
// granted / rejected / revoked: a human answered, somewhere. The actor is
|
|
838
|
+
// the log's, never this listener's configured identity — the answer may
|
|
839
|
+
// have come from another surface entirely.
|
|
840
|
+
settled.push({
|
|
841
|
+
actionKey,
|
|
842
|
+
deliveryId,
|
|
843
|
+
outcome,
|
|
844
|
+
detail: record === undefined
|
|
845
|
+
? [`recorded at ${utcClock(at)}`]
|
|
846
|
+
: [decidedLine(record.actor, record.ts, record.seq)],
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
return settled;
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* One dispatch cycle: re-derive the pending queue from the verified log, send
|
|
853
|
+
* whatever this process has not already sent.
|
|
854
|
+
*
|
|
855
|
+
* `now` is a parameter, not a clock read: TTL judgment inside
|
|
856
|
+
* {@link buildPendingQueue} is deterministic and the tests drive it at chosen
|
|
857
|
+
* instants. Requests that are decided, expired, or not yet requested are absent
|
|
858
|
+
* from the derivation and so are never sent.
|
|
859
|
+
*/
|
|
860
|
+
export async function dispatchPending(setup, streams, state, now) {
|
|
861
|
+
const result = {
|
|
862
|
+
delivered: [],
|
|
863
|
+
failed: [],
|
|
864
|
+
annotated: [],
|
|
865
|
+
digests: [],
|
|
866
|
+
pruned: [],
|
|
867
|
+
};
|
|
868
|
+
const queue = buildPendingQueue(setup.logPath, setup.tagOptions, now);
|
|
869
|
+
if (!queue.ok) {
|
|
870
|
+
result.queueError = { code: queue.code, message: queue.message };
|
|
871
|
+
return result;
|
|
872
|
+
}
|
|
873
|
+
// APRV-106 (withdrawal) generalized by APRV-113 (every terminal state),
|
|
874
|
+
// before the sends. A request this process delivered and that the log now
|
|
875
|
+
// says is settled — granted or rejected at any surface, revoked, expired by
|
|
876
|
+
// the daemon, or withdrawn by its requester — gets its message annotated with
|
|
877
|
+
// that outcome and its buttons taken away, so the approver's phone stops
|
|
878
|
+
// showing a decided question as a live one. What became of it is derived from
|
|
879
|
+
// the VERIFIED log by `terminalDeliveries`, never remembered here; this state
|
|
880
|
+
// only prevents a second edit of the same message.
|
|
881
|
+
for (const settled of terminalDeliveries(setup, state, now)) {
|
|
882
|
+
if (state.annotated.has(settled.actionKey))
|
|
883
|
+
continue;
|
|
884
|
+
state.annotated.add(settled.actionKey);
|
|
885
|
+
try {
|
|
886
|
+
// The action key is what makes this per member on a digest (APRV-115):
|
|
887
|
+
// one delivery id can carry several requests, and settling one of them
|
|
888
|
+
// must leave the others armed.
|
|
889
|
+
await setup.channel.annotate(settled.deliveryId, TELEGRAM_TERMINAL_HEADLINES[settled.outcome], settled.detail, settled.actionKey);
|
|
890
|
+
result.annotated.push({
|
|
891
|
+
action_key: settled.actionKey,
|
|
892
|
+
delivery_id: settled.deliveryId,
|
|
893
|
+
outcome: settled.outcome,
|
|
894
|
+
});
|
|
895
|
+
// APRV-196. The question is over, the message says so, and nothing will
|
|
896
|
+
// ever consult this entry again: the pending queue is derived from the
|
|
897
|
+
// log and a settled request is not in it. Held until now rather than at
|
|
898
|
+
// the moment the log settled, so the annotation pass above still has the
|
|
899
|
+
// message id it needs.
|
|
900
|
+
forget(state, settled.actionKey);
|
|
901
|
+
result.pruned.push({ action_key: settled.actionKey, reason: "settled" });
|
|
902
|
+
if (setup.json) {
|
|
903
|
+
streams.out(`${JSON.stringify({
|
|
904
|
+
event: "annotated",
|
|
905
|
+
action_key: settled.actionKey,
|
|
906
|
+
delivery_id: settled.deliveryId,
|
|
907
|
+
outcome: settled.outcome,
|
|
908
|
+
})}\n`);
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
streams.out(`annotated ${settled.actionKey} (message ${settled.deliveryId}): ${settled.outcome}\n`);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
catch (cause) {
|
|
915
|
+
// APRV-277. Telegram answers an edit that would change nothing with 400
|
|
916
|
+
// "message is not modified", and this pass re-derives its annotations
|
|
917
|
+
// from the verified log rather than remembering which ones landed — so a
|
|
918
|
+
// message this listener (or a previous one, or the channel's own decision
|
|
919
|
+
// path) already annotated produces exactly that. The phone shows the
|
|
920
|
+
// outcome, the annotation stands, and there is nothing to report. Every
|
|
921
|
+
// other 400 and every other failure still reaches the operator below.
|
|
922
|
+
if (isMessageNotModified(cause))
|
|
923
|
+
continue;
|
|
924
|
+
// Cosmetic, and said so on stderr. The gate refuses a tap on the stale
|
|
925
|
+
// buttons anyway (`already-decided`, `request-withdrawn`, `expired`), so
|
|
926
|
+
// nothing can be decided by one.
|
|
927
|
+
streams.err(`approval: telegram could not annotate the ${settled.outcome} ${settled.actionKey} (message ${settled.deliveryId}): ${cause instanceof Error ? cause.message : String(cause)} — the buttons are stale but the gate refuses a tap on them\n`);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
// APRV-196, the other half of the prune: an entry whose message was never
|
|
931
|
+
// annotated (the edit failed, the request lapsed with nothing to say about
|
|
932
|
+
// it, the log settled it while this process was down) would otherwise sit in
|
|
933
|
+
// the map for the life of a listener that `approval up` now keeps running for
|
|
934
|
+
// weeks. Dropped once it is older than the retention window AND the log no
|
|
935
|
+
// longer calls it pending, which is the same pair of conditions the channel's
|
|
936
|
+
// own sweep uses: past that, a re-derivation cannot ask for it and a callback
|
|
937
|
+
// cannot be honoured against it.
|
|
938
|
+
const pendingNow = new Set(queue.requests.map((request) => request.action_key.value));
|
|
939
|
+
const nowMs = Date.parse(now);
|
|
940
|
+
for (const [actionKey, sentAtMs] of state.sentAtMs) {
|
|
941
|
+
if (pendingNow.has(actionKey))
|
|
942
|
+
continue;
|
|
943
|
+
if (Number.isNaN(nowMs) || nowMs - sentAtMs < DISPATCH_RETENTION_MS)
|
|
944
|
+
continue;
|
|
945
|
+
forget(state, actionKey);
|
|
946
|
+
result.pruned.push({ action_key: actionKey, reason: "stale" });
|
|
947
|
+
}
|
|
948
|
+
for (const skipped of queue.skipped) {
|
|
949
|
+
const token = `${skipped.action_key}:${skipped.code}`;
|
|
950
|
+
if (state.warned.has(token))
|
|
951
|
+
continue;
|
|
952
|
+
state.warned.add(token);
|
|
953
|
+
streams.err(`approval: telegram cannot deliver ${skipped.action_key} (${skipped.code}): ${skipped.message}\n`);
|
|
954
|
+
}
|
|
955
|
+
// APRV-257. The checkpoint tap, offered before the requests. Everything about
|
|
956
|
+
// WHETHER to offer is `checkpointOfferFor`, which reads the policy and the
|
|
957
|
+
// verified log; this cycle's only jobs are "has this process already asked"
|
|
958
|
+
// and "is the approver already looking at something".
|
|
959
|
+
//
|
|
960
|
+
// Under `paced` a prompt that went out ends the cycle, because the approver
|
|
961
|
+
// is now looking at a question and sending a request underneath it would be
|
|
962
|
+
// two. Under `burst` it does NOT: burst sends everything pending on every
|
|
963
|
+
// cycle, and `--once` is one cycle, so returning here would leave a startup
|
|
964
|
+
// batch undelivered for the sake of a prompt that blocks nothing.
|
|
965
|
+
const offered = await offerCheckpoint(setup, streams, state, result);
|
|
966
|
+
if (offered && setup.delivery === "paced")
|
|
967
|
+
return result;
|
|
968
|
+
// Everything the log calls pending that this process has not put on the
|
|
969
|
+
// phone. Both modes start here and differ only in how much of it they send.
|
|
970
|
+
const allUndecided = queue.requests.filter((request) => !state.delivered.has(request.action_key.value));
|
|
971
|
+
// APRV-287. A listener that has just started or reconnected re-derives the
|
|
972
|
+
// pending set and re-delivers it. For a queue somebody is waiting on that is
|
|
973
|
+
// exactly right; for one nobody is, it is the flood of 2026-09-06 — a dozen
|
|
974
|
+
// requests whose hooks had long since given up, one message each. The ones
|
|
975
|
+
// older than the hook's wait go out as ONE message with a single reject-all,
|
|
976
|
+
// and the rest are delivered as they always were.
|
|
977
|
+
//
|
|
978
|
+
// `state.banner.sent` is this process's own "have I completed a cycle yet",
|
|
979
|
+
// and it is read here BEFORE the burst banner consumes it: the first cycle of
|
|
980
|
+
// a process is precisely the re-delivery, and a later cycle carries requests
|
|
981
|
+
// that have just been asked.
|
|
982
|
+
// `burst` only, and that is where the harm is. Under `paced` the listener
|
|
983
|
+
// already puts ONE question at a time in front of the approver behind a
|
|
984
|
+
// summary line that names the count, the classes and the oldest age
|
|
985
|
+
// (APRV-216), so a restart there is two messages rather than a dozen and
|
|
986
|
+
// there is no flood to collapse. Collapsing a paced walkthrough would also
|
|
987
|
+
// take away the thing it exists for: an approver working deliberately
|
|
988
|
+
// through an old queue can still approve an old request, and a reject-all
|
|
989
|
+
// summary offers no way to.
|
|
990
|
+
const firstCycle = !state.banner.sent && setup.delivery === "burst";
|
|
991
|
+
const undecided = firstCycle
|
|
992
|
+
? await collapseStale(setup, streams, state, result, allUndecided, now)
|
|
993
|
+
: allUndecided;
|
|
994
|
+
// APRV-216. Under `paced` this cycle sends at most ONE unit, and `null` means
|
|
995
|
+
// it sends nothing because a question is already in front of the approver.
|
|
996
|
+
// Under `burst` it sends everything, which is what this file did before.
|
|
997
|
+
const selected = setup.delivery === "paced" ? pacedSelection(state, queue.requests, undecided) : undecided;
|
|
998
|
+
if (selected === null) {
|
|
999
|
+
// APRV-299. Nothing to SEND is not nothing to do: either a question is
|
|
1000
|
+
// already in front of the approver (in which case the review pass declines
|
|
1001
|
+
// on its own) or the pending queue is empty, which is exactly when the
|
|
1002
|
+
// retrospective backlog should get the screen.
|
|
1003
|
+
await dispatchReviews(setup, streams, state, result, now);
|
|
1004
|
+
return result;
|
|
1005
|
+
}
|
|
1006
|
+
// APRV-115. The window is this cycle: whatever is being sent right now is
|
|
1007
|
+
// grouped, and a group of similar requests goes out as one digest instead of
|
|
1008
|
+
// one message each. Nothing waits for more — there is no new latency
|
|
1009
|
+
// mechanism here, and a lone request is delivered exactly as it always was.
|
|
1010
|
+
//
|
|
1011
|
+
// The gloss is attached to the SELECTED requests and to no others (APRV-216):
|
|
1012
|
+
// under `paced` the rest of the queue is not being rendered this cycle, and
|
|
1013
|
+
// paying 10-15 seconds of subprocess for a sentence nobody will read before
|
|
1014
|
+
// the next decision would be the terminal walker's mistake made here.
|
|
1015
|
+
const tally = { asked: 0, absent: 0 };
|
|
1016
|
+
const undelivered = selected.map((request) => withGloss(setup, request, tally));
|
|
1017
|
+
// APRV-197. One line per cycle, and only when a model was actually asked and
|
|
1018
|
+
// did not answer. Absence used to be silent by design, which was right for
|
|
1019
|
+
// one request and wrong for a thousand: with APRV-144's ceiling the
|
|
1020
|
+
// subprocess missed every time, and the operator's only evidence was prompts
|
|
1021
|
+
// that looked exactly like prompts from before the feature existed.
|
|
1022
|
+
if (tally.absent > 0) {
|
|
1023
|
+
streams.err(glossAbsenceLine("telegram", tally.absent, tally.asked, GLOSS_TIMEOUT_MS));
|
|
1024
|
+
}
|
|
1025
|
+
// APRV-216. The paced opening: one line saying how many are waiting and what
|
|
1026
|
+
// kind, in front of the one being shown. Sent by the first cycle that has
|
|
1027
|
+
// something to show, and again whenever the pending set has GROWN while
|
|
1028
|
+
// nothing was in front of the approver — which is the only moment a count
|
|
1029
|
+
// they were told is stale in the direction that matters.
|
|
1030
|
+
if (setup.delivery === "paced" && undelivered.length > 0) {
|
|
1031
|
+
const pending = queue.requests.length;
|
|
1032
|
+
if (!state.paced.summarySent || pending > state.paced.announced) {
|
|
1033
|
+
state.paced.summarySent = true;
|
|
1034
|
+
state.paced.announced = pending;
|
|
1035
|
+
try {
|
|
1036
|
+
const deliveryId = await setup.channel.announce(summaryLines(queue.requests, now));
|
|
1037
|
+
result.summary = { delivery_id: deliveryId, pending };
|
|
1038
|
+
}
|
|
1039
|
+
catch (cause) {
|
|
1040
|
+
// Cosmetic, exactly like the banner: the request below it is the point,
|
|
1041
|
+
// and withholding a question because its preamble failed to send would
|
|
1042
|
+
// be the wrong direction on the only axis that matters here.
|
|
1043
|
+
streams.err(`approval: telegram could not send the queue summary: ${cause instanceof Error ? cause.message : String(cause)} — the request below is unaffected\n`);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
// APRV-196. The STARTUP batch gets one line in front of it, and only that
|
|
1048
|
+
// one: a later cycle delivers what has just been requested, which is a
|
|
1049
|
+
// notification and not a re-delivery, and a banner over it would say
|
|
1050
|
+
// something false. So the flag is consumed by the first cycle that completes
|
|
1051
|
+
// a derivation whether or not it had anything to send — a listener that
|
|
1052
|
+
// started against an empty queue has no re-delivery to announce, ever. See
|
|
1053
|
+
// {@link bannerLines} for why this is a banner rather than an edit of the
|
|
1054
|
+
// copies that came before.
|
|
1055
|
+
const announcing = setup.delivery === "burst" && !state.banner.sent && undelivered.length > 0;
|
|
1056
|
+
state.banner.sent = true;
|
|
1057
|
+
if (announcing) {
|
|
1058
|
+
try {
|
|
1059
|
+
const deliveryId = await setup.channel.announce(bannerLines(undelivered.length));
|
|
1060
|
+
result.banner = { delivery_id: deliveryId, pending: undelivered.length };
|
|
1061
|
+
}
|
|
1062
|
+
catch (cause) {
|
|
1063
|
+
// Cosmetic, and never a reason to withhold the requests it introduces.
|
|
1064
|
+
streams.err(`approval: telegram could not send the re-delivery banner: ${cause instanceof Error ? cause.message : String(cause)} — the requests below are unaffected\n`);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
const deliveredBefore = result.delivered.length;
|
|
1068
|
+
for (const group of groupForDigest(undelivered)) {
|
|
1069
|
+
if (group.length < 2) {
|
|
1070
|
+
await deliverUnits(setup, streams, state, result, group, now);
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
// B7 first (SPEC.md §10.3): a set carrying more than one distinct payload
|
|
1074
|
+
// where any member is not whole must not be presented as a set at all. The
|
|
1075
|
+
// refusal is reported once and the members go out individually, which is
|
|
1076
|
+
// the same direction every other digest fallback takes.
|
|
1077
|
+
const assembled = assembleBatch(group);
|
|
1078
|
+
if (!assembled.ok) {
|
|
1079
|
+
const token = `${group.map((request) => request.action_key.value).join(",")}:${assembled.code}`;
|
|
1080
|
+
if (!state.warned.has(token)) {
|
|
1081
|
+
state.warned.add(token);
|
|
1082
|
+
streams.err(`approval: telegram cannot digest ${group.length} similar requests (${assembled.code}): ${assembled.message} — sending them one message each instead\n`);
|
|
1083
|
+
}
|
|
1084
|
+
await deliverUnits(setup, streams, state, result, group, now);
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
const keys = group.map((request) => request.action_key.value);
|
|
1088
|
+
try {
|
|
1089
|
+
const delivered = await setup.channel.notifyBatch(assembled.batch);
|
|
1090
|
+
for (const member of delivered.members) {
|
|
1091
|
+
state.delivered.set(member.action_key, member.delivery_id);
|
|
1092
|
+
remember(state, member.action_key, now);
|
|
1093
|
+
state.attempts.delete(member.action_key);
|
|
1094
|
+
result.delivered.push({ action_key: member.action_key, delivery_id: member.delivery_id });
|
|
1095
|
+
}
|
|
1096
|
+
if (delivered.digestId !== null) {
|
|
1097
|
+
result.digests.push({
|
|
1098
|
+
delivery_id: delivered.digestId,
|
|
1099
|
+
batch_delivery_id: delivered.batchDeliveryId,
|
|
1100
|
+
action_keys: delivered.members.map((member) => member.action_key),
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
report(setup, streams, delivered.digestId, delivered.members);
|
|
1104
|
+
}
|
|
1105
|
+
catch (cause) {
|
|
1106
|
+
// Every member stays out of `delivered`, so the next cycle re-sends the
|
|
1107
|
+
// whole group. A half-sent digest arms nothing (the member nonces are
|
|
1108
|
+
// registered only once the message with the buttons exists), so the cost
|
|
1109
|
+
// is a duplicate prompt and never a live button on a partial set.
|
|
1110
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
1111
|
+
for (const actionKey of keys) {
|
|
1112
|
+
const attempts = (state.attempts.get(actionKey) ?? 0) + 1;
|
|
1113
|
+
state.attempts.set(actionKey, attempts);
|
|
1114
|
+
result.failed.push({ action_key: actionKey, attempts, message });
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
// APRV-216. What is in front of the approver is what actually reached the
|
|
1119
|
+
// chat, which is why this reads the RESULT rather than the selection: a send
|
|
1120
|
+
// that failed leaves `current` null, so the next cycle retries the same unit
|
|
1121
|
+
// instead of standing still behind a message nobody ever received.
|
|
1122
|
+
if (setup.delivery === "paced") {
|
|
1123
|
+
const sent = result.delivered.slice(deliveredBefore).map((entry) => entry.action_key);
|
|
1124
|
+
state.paced.current = sent.length === 0 ? null : sent;
|
|
1125
|
+
}
|
|
1126
|
+
// APRV-299, last and least urgent. The retrospective backlog is offered only
|
|
1127
|
+
// when nothing this cycle put a question in front of the approver: a pending
|
|
1128
|
+
// request is somebody waiting, and a sampled action is somebody's work that
|
|
1129
|
+
// already finished. Reconciliation runs even when no card goes out, so a
|
|
1130
|
+
// sample reviewed at a terminal releases the walkthrough on the next cycle.
|
|
1131
|
+
await dispatchReviews(setup, streams, state, result, now);
|
|
1132
|
+
return result;
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* One cycle's worth of "put the retrospective backlog in front of the approver"
|
|
1136
|
+
* (APRV-299).
|
|
1137
|
+
*
|
|
1138
|
+
* The same shape as the paced request walkthrough and for the same reasons. The
|
|
1139
|
+
* LOG decides what exists: `openReviewCards` re-derives the open samples from
|
|
1140
|
+
* verified records every cycle, so a sample reviewed anywhere — this card, a
|
|
1141
|
+
* terminal, another listener — leaves the order and the shown slot, and nothing
|
|
1142
|
+
* this process remembers can keep a reviewed sample on the phone or an open one
|
|
1143
|
+
* off it. The ORDER is this process's, seeded from log order and rearranged by
|
|
1144
|
+
* `/skip` alone. And ONE AT A TIME: a card in front of the approver means this
|
|
1145
|
+
* cycle sends nothing.
|
|
1146
|
+
*
|
|
1147
|
+
* Never fatal, at startup or after. A backlog that cannot be derived is a
|
|
1148
|
+
* stderr line and a `reviewError` on the result; a card that fails to send
|
|
1149
|
+
* leaves the sample undelivered and the next cycle retries it. Neither can lose
|
|
1150
|
+
* a review, because the sample is in the log and the log is what is read.
|
|
1151
|
+
*/
|
|
1152
|
+
/** The log's size in bytes, or `null` when it cannot be stated. */
|
|
1153
|
+
function logSizeOf(logPath) {
|
|
1154
|
+
try {
|
|
1155
|
+
return statSync(logPath).size;
|
|
1156
|
+
}
|
|
1157
|
+
catch {
|
|
1158
|
+
return null;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
async function dispatchReviews(setup, streams, state, result, now) {
|
|
1162
|
+
const review = state.review;
|
|
1163
|
+
// The cost guard. A card is already in front of the approver and the log has
|
|
1164
|
+
// not grown since this pass last ran, so nothing can have been reviewed and
|
|
1165
|
+
// nothing can have been sampled: skip the verified walk. Sound because the
|
|
1166
|
+
// log is append-only; a `null` size, an unreadable stat, or any growth at all
|
|
1167
|
+
// falls through to the ordinary derivation.
|
|
1168
|
+
const size = logSizeOf(setup.logPath);
|
|
1169
|
+
if (review.current !== null && size !== null && size === review.logSize)
|
|
1170
|
+
return;
|
|
1171
|
+
const built = openReviewCards(setup.logPath, setup.tagOptions.payload === undefined ? {} : { payload: setup.tagOptions.payload });
|
|
1172
|
+
if (!built.ok) {
|
|
1173
|
+
result.reviewError = { code: built.code, message: built.message };
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
review.logSize = size;
|
|
1177
|
+
const open = built.cards;
|
|
1178
|
+
const openSeqs = new Set(open.map((card) => card.sampleSeq));
|
|
1179
|
+
review.order = review.order.filter((seq) => openSeqs.has(seq));
|
|
1180
|
+
const known = new Set(review.order);
|
|
1181
|
+
for (const card of open) {
|
|
1182
|
+
if (!known.has(card.sampleSeq))
|
|
1183
|
+
review.order.push(card.sampleSeq);
|
|
1184
|
+
}
|
|
1185
|
+
// Deleting the current entry mid-iteration is defined behaviour for a Map.
|
|
1186
|
+
for (const seq of review.delivered.keys()) {
|
|
1187
|
+
if (!openSeqs.has(seq))
|
|
1188
|
+
review.delivered.delete(seq);
|
|
1189
|
+
}
|
|
1190
|
+
if (review.current !== null && !openSeqs.has(review.current))
|
|
1191
|
+
review.current = null;
|
|
1192
|
+
// A count the approver was told that is now too high is the number they
|
|
1193
|
+
// watched go down, not growth to announce again.
|
|
1194
|
+
review.announced = Math.min(review.announced, open.length);
|
|
1195
|
+
if (review.current !== null)
|
|
1196
|
+
return;
|
|
1197
|
+
if (setup.delivery === "paced" && state.paced.current !== null)
|
|
1198
|
+
return;
|
|
1199
|
+
const nextSeq = review.order.find((seq) => !review.delivered.has(seq));
|
|
1200
|
+
if (nextSeq === undefined)
|
|
1201
|
+
return;
|
|
1202
|
+
const card = open.find((entry) => entry.sampleSeq === nextSeq);
|
|
1203
|
+
if (card === undefined)
|
|
1204
|
+
return;
|
|
1205
|
+
if (!review.summarySent || open.length > review.announced) {
|
|
1206
|
+
review.summarySent = true;
|
|
1207
|
+
review.announced = open.length;
|
|
1208
|
+
try {
|
|
1209
|
+
const deliveryId = await setup.channel.announce(reviewSummaryLines(open, now));
|
|
1210
|
+
result.reviewSummary = { delivery_id: deliveryId, open: open.length };
|
|
1211
|
+
}
|
|
1212
|
+
catch (cause) {
|
|
1213
|
+
// Cosmetic, exactly like the request summary: the card below it is the
|
|
1214
|
+
// point, and withholding it because its preamble failed would be the
|
|
1215
|
+
// wrong direction on the only axis that matters.
|
|
1216
|
+
streams.err(`approval: telegram could not send the review summary: ${cause instanceof Error ? cause.message : String(cause)} — the card below is unaffected\n`);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
const actionKey = card.fields.action_key.value;
|
|
1220
|
+
try {
|
|
1221
|
+
const deliveryId = await setup.channel.offerReview(card);
|
|
1222
|
+
review.delivered.set(nextSeq, deliveryId);
|
|
1223
|
+
review.current = nextSeq;
|
|
1224
|
+
result.reviewCard = {
|
|
1225
|
+
delivery_id: deliveryId,
|
|
1226
|
+
sample_seq: nextSeq,
|
|
1227
|
+
action_key: actionKey,
|
|
1228
|
+
};
|
|
1229
|
+
if (setup.json) {
|
|
1230
|
+
streams.out(`${JSON.stringify({
|
|
1231
|
+
event: "review_offered",
|
|
1232
|
+
sample_seq: nextSeq,
|
|
1233
|
+
action_key: actionKey,
|
|
1234
|
+
delivery_id: deliveryId,
|
|
1235
|
+
})}\n`);
|
|
1236
|
+
}
|
|
1237
|
+
else {
|
|
1238
|
+
streams.out(`offered a review of sample seq ${String(nextSeq)} (${actionKey}, message ${deliveryId})\n`);
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
catch (cause) {
|
|
1242
|
+
// The sample stays open and undelivered, so the next cycle offers it again.
|
|
1243
|
+
streams.err(`approval: telegram could not offer the review of sample seq ${String(nextSeq)} (${actionKey}): ${cause instanceof Error ? cause.message : String(cause)} — the sample stays open and the next cycle tries again\n`);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* How old a pending request must be, on a listener's first cycle, to be one
|
|
1248
|
+
* nobody is waiting on (APRV-287).
|
|
1249
|
+
*
|
|
1250
|
+
* The hook's own wait PLUS its retry grace, read from the same module the hook
|
|
1251
|
+
* reads (`core/harness-wait.ts`), because two numbers would be two answers to
|
|
1252
|
+
* "is anybody still holding this". Past the wait alone a hook process has
|
|
1253
|
+
* stopped blocking and a retry can still adopt the question, so those are
|
|
1254
|
+
* ordinary pending requests. Past the wait and the grace together nothing will
|
|
1255
|
+
* adopt it: that is the moment the hook itself takes such a request back, and a
|
|
1256
|
+
* request still pending here is one whose session never came back at all —
|
|
1257
|
+
* exactly the dozen that arrived on a phone behind a dead daemon on
|
|
1258
|
+
* 2026-09-06.
|
|
1259
|
+
*
|
|
1260
|
+
* Collapsing is not deciding. These stay pending, listable by `/queue`, and
|
|
1261
|
+
* decidable from any copy already delivered; what changes is how many messages
|
|
1262
|
+
* it takes to say they are there.
|
|
1263
|
+
*/
|
|
1264
|
+
export const COLLAPSE_STALE_AFTER_MS = abandonedAfterMs(HOOK_DEFAULT_WAIT_MS, HOOK_RETRY_GRACE_MS);
|
|
1265
|
+
/**
|
|
1266
|
+
* How many stale requests it takes to collapse them (APRV-287).
|
|
1267
|
+
*
|
|
1268
|
+
* Two. A lone stale request keeps its own card, with its payload and both
|
|
1269
|
+
* buttons, because collapsing it would take away the ability to approve it and
|
|
1270
|
+
* save nobody a message. A flood starts at two.
|
|
1271
|
+
*/
|
|
1272
|
+
const COLLAPSE_MIN = 2;
|
|
1273
|
+
/**
|
|
1274
|
+
* A DURATION in words, which is not what {@link ageText} renders.
|
|
1275
|
+
*
|
|
1276
|
+
* `ageText` says how long ago something happened ("5 min ago"), and these two
|
|
1277
|
+
* numbers are lengths of time rather than instants: writing "older than the
|
|
1278
|
+
* hook's 5 min ago retry grace" would be a sentence about the wrong kind of
|
|
1279
|
+
* thing.
|
|
1280
|
+
*/
|
|
1281
|
+
function durationText(ms) {
|
|
1282
|
+
if (ms < 60_000)
|
|
1283
|
+
return `${String(Math.round(ms / 1000))}s`;
|
|
1284
|
+
const minutes = Math.round(ms / 60_000);
|
|
1285
|
+
return `${String(minutes)}m`;
|
|
1286
|
+
}
|
|
1287
|
+
/** The computed lines a collapsed re-delivery leads with (APRV-287). */
|
|
1288
|
+
export function staleLines(requests, now) {
|
|
1289
|
+
const nowMs = Date.parse(now);
|
|
1290
|
+
const ages = requests
|
|
1291
|
+
.map((request) => nowMs - Date.parse(request.requested_ts.value))
|
|
1292
|
+
.filter((age) => !Number.isNaN(age));
|
|
1293
|
+
const oldest = ages.length === 0 ? null : Math.max(...ages);
|
|
1294
|
+
const tally = new Map();
|
|
1295
|
+
for (const request of requests) {
|
|
1296
|
+
const cls = request.class.value;
|
|
1297
|
+
tally.set(cls, (tally.get(cls) ?? 0) + 1);
|
|
1298
|
+
}
|
|
1299
|
+
return [
|
|
1300
|
+
`${String(requests.length)} pending requests, all older than the hook's ${durationText(HOOK_DEFAULT_WAIT_MS)} wait plus its ${durationText(HOOK_RETRY_GRACE_MS)} retry grace`,
|
|
1301
|
+
`oldest: ${oldest === null ? "unknown age" : ageText(oldest)}`,
|
|
1302
|
+
`classes: ${[...tally.entries()]
|
|
1303
|
+
.map(([cls, count]) => (count === 1 ? cls : `${cls} ×${String(count)}`))
|
|
1304
|
+
.join(", ")}`,
|
|
1305
|
+
"collapsed into this one message because the tool calls that asked have stopped waiting; a decision on any of them can still authorize an identical retry",
|
|
1306
|
+
];
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Put the requests nobody is waiting on into ONE message, and hand back the
|
|
1310
|
+
* ones that still get a message each (APRV-287).
|
|
1311
|
+
*
|
|
1312
|
+
* Called on a process's first cycle only, which is exactly a daemon start or a
|
|
1313
|
+
* listener reconnect. Everything it does is bookkeeping in the sense SPEC.md
|
|
1314
|
+
* §10.3 fixes: the pending set is re-derived from the verified log every cycle,
|
|
1315
|
+
* so a summary that fails to send, or a process that forgets it sent one,
|
|
1316
|
+
* degrades to showing those requests again, and never to a pending request
|
|
1317
|
+
* nobody is shown.
|
|
1318
|
+
*/
|
|
1319
|
+
async function collapseStale(setup, streams, state, result, undecided, now) {
|
|
1320
|
+
const nowMs = Date.parse(now);
|
|
1321
|
+
if (Number.isNaN(nowMs))
|
|
1322
|
+
return undecided;
|
|
1323
|
+
const age = (request) => {
|
|
1324
|
+
const at = Date.parse(request.requested_ts.value);
|
|
1325
|
+
return Number.isNaN(at) ? 0 : nowMs - at;
|
|
1326
|
+
};
|
|
1327
|
+
const stale = undecided.filter((request) => age(request) >= COLLAPSE_STALE_AFTER_MS);
|
|
1328
|
+
if (stale.length < COLLAPSE_MIN)
|
|
1329
|
+
return undecided;
|
|
1330
|
+
let delivered = null;
|
|
1331
|
+
try {
|
|
1332
|
+
delivered = await setup.channel.notifyStale(stale, { lines: staleLines(stale, now) });
|
|
1333
|
+
}
|
|
1334
|
+
catch (cause) {
|
|
1335
|
+
delivered = null;
|
|
1336
|
+
streams.err(`approval: telegram could not send the collapsed re-delivery of ${String(stale.length)} stale requests: ${cause instanceof Error ? cause.message : String(cause)} — they are delivered one message each instead\n`);
|
|
1337
|
+
}
|
|
1338
|
+
if (delivered === null || delivered.digestId === null) {
|
|
1339
|
+
// Degrades to showing the requests again, which is this bookkeeping's rule.
|
|
1340
|
+
return undecided;
|
|
1341
|
+
}
|
|
1342
|
+
const digestId = delivered.digestId;
|
|
1343
|
+
for (const member of delivered.members) {
|
|
1344
|
+
state.delivered.set(member.action_key, member.delivery_id);
|
|
1345
|
+
remember(state, member.action_key, now);
|
|
1346
|
+
state.attempts.delete(member.action_key);
|
|
1347
|
+
result.delivered.push({ action_key: member.action_key, delivery_id: member.delivery_id });
|
|
1348
|
+
}
|
|
1349
|
+
result.collapsed = {
|
|
1350
|
+
delivery_id: digestId,
|
|
1351
|
+
action_keys: delivered.members.map((member) => member.action_key),
|
|
1352
|
+
oldest_ms: Math.max(...stale.map((request) => age(request))),
|
|
1353
|
+
};
|
|
1354
|
+
report(setup, streams, digestId, delivered.members);
|
|
1355
|
+
const collapsedKeys = new Set(delivered.members.map((member) => member.action_key));
|
|
1356
|
+
return undecided.filter((request) => !collapsedKeys.has(request.action_key.value));
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* The checkpoint prompt, at most one outstanding and never a nag (APRV-257).
|
|
1360
|
+
*
|
|
1361
|
+
* Returns `true` when this cycle sent one; the caller decides what that means,
|
|
1362
|
+
* and under `paced` it means the cycle is over. It costs the queue one cycle
|
|
1363
|
+
* and never more, because a checkpoint prompt is never `paced.current` —
|
|
1364
|
+
* nothing releases it, so nothing could be left waiting on it.
|
|
1365
|
+
*
|
|
1366
|
+
* Three conditions, and each one is a different failure it avoids:
|
|
1367
|
+
*
|
|
1368
|
+
* 1. **Nothing already in front of the approver** (`paced` only). The whole
|
|
1369
|
+
* content of APRV-216 is one question at a time, and a checkpoint is a
|
|
1370
|
+
* question.
|
|
1371
|
+
* 2. **Not already asked for this lapse.** `state.checkpoint.offeredSince`
|
|
1372
|
+
* holds the newest checkpoint's seq at the moment the last prompt went out.
|
|
1373
|
+
* A lapsed cadence produces an offer on every cycle for as long as it lasts,
|
|
1374
|
+
* and a listener that sent one every cycle would be the nag APRV-220 refused
|
|
1375
|
+
* to build. The value moves only when a checkpoint actually LANDS, which is
|
|
1376
|
+
* also when due-ness goes false — so the next prompt comes from the next
|
|
1377
|
+
* lapse.
|
|
1378
|
+
* 3. **The policy asked for it.** No cadence, or no key, and there is no offer
|
|
1379
|
+
* at all; `checkpointOfferFor` decides that and this function never second-
|
|
1380
|
+
* guesses it.
|
|
1381
|
+
*
|
|
1382
|
+
* A send that fails leaves `offered` false, so the next cycle tries again — the
|
|
1383
|
+
* same direction a failed request send takes, and for the same reason.
|
|
1384
|
+
*/
|
|
1385
|
+
async function offerCheckpoint(setup, streams, state, result) {
|
|
1386
|
+
if (setup.delivery === "paced" && state.paced.current !== null)
|
|
1387
|
+
return false;
|
|
1388
|
+
const offer = checkpointOfferFor(setup.checkpoint);
|
|
1389
|
+
if (offer === null)
|
|
1390
|
+
return false;
|
|
1391
|
+
if (state.checkpoint.offered && state.checkpoint.offeredSince === offer.since)
|
|
1392
|
+
return false;
|
|
1393
|
+
try {
|
|
1394
|
+
const deliveryId = await setup.channel.offerCheckpoint({
|
|
1395
|
+
head: offer.head,
|
|
1396
|
+
lines: checkpointPromptLines(offer),
|
|
1397
|
+
});
|
|
1398
|
+
state.checkpoint.offered = true;
|
|
1399
|
+
state.checkpoint.offeredSince = offer.since;
|
|
1400
|
+
result.checkpoint = {
|
|
1401
|
+
delivery_id: deliveryId,
|
|
1402
|
+
seq: offer.head.seq,
|
|
1403
|
+
hash: offer.head.hash,
|
|
1404
|
+
};
|
|
1405
|
+
if (setup.json) {
|
|
1406
|
+
streams.out(`${JSON.stringify({
|
|
1407
|
+
event: "checkpoint_offered",
|
|
1408
|
+
delivery_id: deliveryId,
|
|
1409
|
+
seq: offer.head.seq,
|
|
1410
|
+
hash: offer.head.hash,
|
|
1411
|
+
})}\n`);
|
|
1412
|
+
}
|
|
1413
|
+
else {
|
|
1414
|
+
streams.out(`offered a checkpoint of seq ${String(offer.head.seq)} (message ${deliveryId})\n`);
|
|
1415
|
+
}
|
|
1416
|
+
return true;
|
|
1417
|
+
}
|
|
1418
|
+
catch (cause) {
|
|
1419
|
+
// Never fatal, not even at startup. A checkpoint that is due is a warning
|
|
1420
|
+
// at every layer, so a listener that refused to start because it could not
|
|
1421
|
+
// ASK for one would have turned a warning into an outage.
|
|
1422
|
+
streams.err(`approval: telegram could not offer a checkpoint of seq ${String(offer.head.seq)}: ${cause instanceof Error ? cause.message : String(cause)} — nothing was signed and the next cycle tries again\n`);
|
|
1423
|
+
return false;
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* What a paced cycle sends: one unit, or nothing (APRV-216).
|
|
1428
|
+
*
|
|
1429
|
+
* It re-reconciles the walkthrough against the verified log first, and that
|
|
1430
|
+
* order is the whole design:
|
|
1431
|
+
*
|
|
1432
|
+
* 1. **The log decides what exists.** Keys the derivation no longer carries
|
|
1433
|
+
* leave the order and leave the shown unit, however they left the queue —
|
|
1434
|
+
* granted here, rejected from the terminal, withdrawn by their requester,
|
|
1435
|
+
* expired by the daemon. So a decision made anywhere advances the
|
|
1436
|
+
* walkthrough, and nothing this process remembers can keep a settled
|
|
1437
|
+
* question on the phone or a live one off it.
|
|
1438
|
+
* 2. **The order is this process's.** Newly pending keys join the back in log
|
|
1439
|
+
* order (oldest first); `/skip` is the only thing that rearranges it.
|
|
1440
|
+
* 3. **One at a time.** A unit still holding a pending member means a question
|
|
1441
|
+
* is in front of the approver, and this cycle sends nothing.
|
|
1442
|
+
*
|
|
1443
|
+
* The unit is a digest group rather than a single request when the oldest
|
|
1444
|
+
* pending request has similar company (APRV-115): what is being paced is the
|
|
1445
|
+
* approver's ATTENTION, and four identical `network.call`s are one thing to
|
|
1446
|
+
* read whether or not they are four things to decide.
|
|
1447
|
+
*/
|
|
1448
|
+
function pacedSelection(state, pending, undelivered) {
|
|
1449
|
+
const paced = state.paced;
|
|
1450
|
+
const pendingKeys = pending.map((request) => request.action_key.value);
|
|
1451
|
+
const pendingSet = new Set(pendingKeys);
|
|
1452
|
+
paced.order = paced.order.filter((key) => pendingSet.has(key));
|
|
1453
|
+
const known = new Set(paced.order);
|
|
1454
|
+
for (const key of pendingKeys) {
|
|
1455
|
+
if (!known.has(key))
|
|
1456
|
+
paced.order.push(key);
|
|
1457
|
+
}
|
|
1458
|
+
if (paced.current !== null) {
|
|
1459
|
+
paced.current = paced.current.filter((key) => pendingSet.has(key));
|
|
1460
|
+
if (paced.current.length === 0)
|
|
1461
|
+
paced.current = null;
|
|
1462
|
+
}
|
|
1463
|
+
// A count the approver was told that is now too high is not a growth to
|
|
1464
|
+
// announce; it is the number they watched go down. Clamping here is what
|
|
1465
|
+
// makes "the pending set grew" mean growth from wherever it actually is.
|
|
1466
|
+
paced.announced = Math.min(paced.announced, pendingKeys.length);
|
|
1467
|
+
if (paced.current !== null)
|
|
1468
|
+
return null;
|
|
1469
|
+
const available = new Map(undelivered.map((request) => [request.action_key.value, request]));
|
|
1470
|
+
const nextKey = paced.order.find((key) => available.has(key));
|
|
1471
|
+
if (nextKey === undefined)
|
|
1472
|
+
return null;
|
|
1473
|
+
return (groupForDigest(undelivered).find((group) => group.some((request) => request.action_key.value === nextKey)) ?? null);
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* The request, plus a model's one-sentence gloss when one can be had (APRV-144).
|
|
1477
|
+
*
|
|
1478
|
+
* The attaching itself moved to `cli/gloss-attach.ts` in APRV-197, when the
|
|
1479
|
+
* terminal channel needed the same thing; what stays here is the listener's own
|
|
1480
|
+
* two decisions. Whether to ask at all is `setup.gloss`, which the verb sets
|
|
1481
|
+
* only under `--gloss`. What to do with the answer is nothing, except count it:
|
|
1482
|
+
* absence used to be silent by design, and with the old 2s ceiling that made a
|
|
1483
|
+
* chronically failing subprocess indistinguishable from a feature nobody built.
|
|
1484
|
+
*
|
|
1485
|
+
* Once per request, not once per cycle: a request already in `delivered` never
|
|
1486
|
+
* reaches this.
|
|
1487
|
+
*/
|
|
1488
|
+
function withGloss(setup, request, tally) {
|
|
1489
|
+
if (setup.gloss === undefined)
|
|
1490
|
+
return request;
|
|
1491
|
+
const attached = attachGloss(request, setup.gloss);
|
|
1492
|
+
if (attached.outcome !== "opaque")
|
|
1493
|
+
tally.asked += 1;
|
|
1494
|
+
if (attached.outcome === "absent")
|
|
1495
|
+
tally.absent += 1;
|
|
1496
|
+
return attached.request;
|
|
1497
|
+
}
|
|
1498
|
+
/** Deliver each request as its own prompt: the pre-digest path, unchanged. */
|
|
1499
|
+
async function deliverUnits(setup, streams, state, result, requests, now) {
|
|
1500
|
+
for (const request of requests) {
|
|
1501
|
+
const actionKey = request.action_key.value;
|
|
1502
|
+
try {
|
|
1503
|
+
const deliveryId = await setup.channel.notify(request);
|
|
1504
|
+
state.delivered.set(actionKey, deliveryId);
|
|
1505
|
+
remember(state, actionKey, now);
|
|
1506
|
+
state.attempts.delete(actionKey);
|
|
1507
|
+
result.delivered.push({ action_key: actionKey, delivery_id: deliveryId });
|
|
1508
|
+
report(setup, streams, null, [{ action_key: actionKey, delivery_id: deliveryId }]);
|
|
1509
|
+
}
|
|
1510
|
+
catch (cause) {
|
|
1511
|
+
// The key stays out of `delivered`, so the next cycle tries again.
|
|
1512
|
+
const attempts = (state.attempts.get(actionKey) ?? 0) + 1;
|
|
1513
|
+
state.attempts.set(actionKey, attempts);
|
|
1514
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
1515
|
+
result.failed.push({ action_key: actionKey, attempts, message });
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
/** One `notified` line (or JSON object) per request actually delivered. */
|
|
1520
|
+
function report(setup, streams, digestId, members) {
|
|
1521
|
+
for (const member of members) {
|
|
1522
|
+
if (setup.json) {
|
|
1523
|
+
streams.out(`${JSON.stringify({
|
|
1524
|
+
event: "notified",
|
|
1525
|
+
action_key: member.action_key,
|
|
1526
|
+
delivery_id: member.delivery_id,
|
|
1527
|
+
...(digestId === null ? {} : { digest_id: digestId, digest_size: members.length }),
|
|
1528
|
+
})}\n`);
|
|
1529
|
+
}
|
|
1530
|
+
else {
|
|
1531
|
+
streams.out(digestId === null
|
|
1532
|
+
? `notified ${member.action_key} (message ${member.delivery_id})\n`
|
|
1533
|
+
: `notified ${member.action_key} (digest ${digestId}, ${members.length} requests)\n`);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
/**
|
|
1538
|
+
* Report a steady-state cycle's problems on stderr. Startup reports its own,
|
|
1539
|
+
* as exit codes, in {@link runListener}.
|
|
1540
|
+
*/
|
|
1541
|
+
function reportCycle(result, streams) {
|
|
1542
|
+
if (result.queueError !== undefined) {
|
|
1543
|
+
streams.err(`approval: telegram cannot read the pending queue (${result.queueError.code}): ${result.queueError.message} — retrying next cycle\n`);
|
|
1544
|
+
}
|
|
1545
|
+
for (const failure of result.failed) {
|
|
1546
|
+
// Loud for the first few, then every tenth: an outage must stay visible
|
|
1547
|
+
// without turning the operator's terminal into a log of one message.
|
|
1548
|
+
if (failure.attempts > DISPATCH_LOUD_ATTEMPTS && failure.attempts % 10 !== 0)
|
|
1549
|
+
continue;
|
|
1550
|
+
streams.err(`approval: telegram sendMessage failed for ${failure.action_key} (attempt ${failure.attempts}): ${failure.message} — still pending, retrying next cycle\n`);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
/** The decision handler: the only thing this process does with a button press. */
|
|
1554
|
+
function handlerFor(setup, streams) {
|
|
1555
|
+
return (decision) => {
|
|
1556
|
+
const result = recordChannelDecision(setup.logPath, decision, { actor: setup.actor, channel: "telegram" }, setup.gateOptions);
|
|
1557
|
+
if (setup.json) {
|
|
1558
|
+
streams.out(`${JSON.stringify({
|
|
1559
|
+
event: "decision",
|
|
1560
|
+
action_key: decision.action_key,
|
|
1561
|
+
decision: decision.decision,
|
|
1562
|
+
ok: result.outcome.ok,
|
|
1563
|
+
...(result.outcome.ok
|
|
1564
|
+
? { seq: result.outcome.record.seq, state: result.outcome.state }
|
|
1565
|
+
: { code: result.outcome.code }),
|
|
1566
|
+
// The token is NEVER in the JSON stream either: --json output is the
|
|
1567
|
+
// thing most likely to be piped into a file or a log aggregator.
|
|
1568
|
+
token_issued: result.token !== undefined,
|
|
1569
|
+
})}\n`);
|
|
1570
|
+
}
|
|
1571
|
+
else if (result.outcome.ok) {
|
|
1572
|
+
streams.out(`${decision.decision === "grant" ? "granted" : "rejected"} ${decision.action_key} (seq ${result.outcome.record.seq}) by ${setup.actor} via telegram\n`);
|
|
1573
|
+
}
|
|
1574
|
+
else {
|
|
1575
|
+
streams.err(`approval: telegram decision refused (${result.outcome.code}): ${result.outcome.message}\n`);
|
|
1576
|
+
}
|
|
1577
|
+
// APRV-17: the raw token is printed exactly once, here, on this terminal's
|
|
1578
|
+
// stdout. It is not sent to Telegram (a chat transcript is not a secrets
|
|
1579
|
+
// channel), not written to the log (which holds only its sha256), and not
|
|
1580
|
+
// handed back to the channel. Once this line scrolls away it is gone.
|
|
1581
|
+
if (result.token !== undefined) {
|
|
1582
|
+
// APRV-102: the shared rule-boxed panel, with the Telegram clause of the
|
|
1583
|
+
// notice — the one surface where "not sent to Telegram" is a fact the
|
|
1584
|
+
// reader might otherwise doubt, having just decided in a chat window.
|
|
1585
|
+
streams.out(`${tokenPanel(style(), decision.action_key, result.token, TOKEN_NOTICE_TELEGRAM)}\n`);
|
|
1586
|
+
}
|
|
1587
|
+
return result.outcome;
|
|
1588
|
+
};
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* What a checkpoint tap does, on the machine the listener runs on (APRV-257).
|
|
1592
|
+
*
|
|
1593
|
+
* The signing happens HERE, in the listener's process, and that is the whole
|
|
1594
|
+
* point of the tap: this process holds the vault passphrase because a HUMAN
|
|
1595
|
+
* exported it into the shell they started it from, and `core/child-env.ts`
|
|
1596
|
+
* strips that variable from every child an agent's session spawns. No agent can
|
|
1597
|
+
* arrange for a process that reaches this function with a key.
|
|
1598
|
+
*
|
|
1599
|
+
* Nothing about the head is re-derived. The `(seq, hash)` comes back from the
|
|
1600
|
+
* channel exactly as it was put on the screen, and
|
|
1601
|
+
* {@link ../core/checkpoint.js appendCheckpointAt} signs that and checks the
|
|
1602
|
+
* log still carries it. A handler that quietly re-read the head would be
|
|
1603
|
+
* putting a human's key over bytes nobody looked at.
|
|
1604
|
+
*
|
|
1605
|
+
* `Not now` appends nothing and says so. It is not a rejection: there is no
|
|
1606
|
+
* request here to reject, and a checkpoint that is owed is a warning at every
|
|
1607
|
+
* layer and a refusal at none.
|
|
1608
|
+
*/
|
|
1609
|
+
export function checkpointHandlerFor(setup, streams) {
|
|
1610
|
+
return (tap) => {
|
|
1611
|
+
if (!tap.sign) {
|
|
1612
|
+
return {
|
|
1613
|
+
ok: true,
|
|
1614
|
+
headline: "NOT SIGNED",
|
|
1615
|
+
detail: [
|
|
1616
|
+
`The checkpoint of seq ${String(tap.head.seq)} was declined. Nothing was appended.`,
|
|
1617
|
+
"A checkpoint that is owed is a warning and never a refusal; you will be asked again when the next one is due.",
|
|
1618
|
+
],
|
|
1619
|
+
toast: "Not now.",
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
const result = signCheckpointOffer(setup.checkpoint, tap.head, setup.actor, "telegram", process.cwd());
|
|
1623
|
+
const lines = checkpointSignedLines(result);
|
|
1624
|
+
const [headline, ...detail] = lines;
|
|
1625
|
+
if (setup.json) {
|
|
1626
|
+
streams.out(`${JSON.stringify({
|
|
1627
|
+
event: "checkpoint",
|
|
1628
|
+
ok: result.ok,
|
|
1629
|
+
seq: result.ok ? result.seq : null,
|
|
1630
|
+
signed: tap.head,
|
|
1631
|
+
...(result.ok ? {} : { code: result.code }),
|
|
1632
|
+
})}\n`);
|
|
1633
|
+
}
|
|
1634
|
+
else if (result.ok) {
|
|
1635
|
+
streams.out(`checkpoint ${String(result.seq)}: signed head seq ${String(result.signed.seq)} ${result.signed.hash} by ${setup.actor} via telegram\n`);
|
|
1636
|
+
}
|
|
1637
|
+
else {
|
|
1638
|
+
streams.err(`approval: telegram checkpoint refused (${result.code}): ${result.message}\n`);
|
|
1639
|
+
}
|
|
1640
|
+
return {
|
|
1641
|
+
ok: result.ok,
|
|
1642
|
+
headline: headline ?? "NOT CHECKPOINTED",
|
|
1643
|
+
detail,
|
|
1644
|
+
toast: result.ok ? "Signed." : "Not signed — the message says why.",
|
|
1645
|
+
};
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
/**
|
|
1649
|
+
* What a review tap does: the human-only `reviewSample`, and nothing else
|
|
1650
|
+
* (APRV-299).
|
|
1651
|
+
*
|
|
1652
|
+
* The one path from a button on a phone to an `audit.reviewed`, and it is the
|
|
1653
|
+
* SAME path `approval audit review` takes — same function, same refusals, same
|
|
1654
|
+
* record shape — so a reaction given on a card and one given at a terminal are
|
|
1655
|
+
* indistinguishable to `approval feedback`, which is the whole of AC3.
|
|
1656
|
+
*
|
|
1657
|
+
* The actor is `setup.actor`, the human identity this listener was configured
|
|
1658
|
+
* with (`--as` / `APPROVAL_HUMAN`), exactly as a grant's is. It is never read
|
|
1659
|
+
* off the tap, never off the callback, and never out of a payload field: this
|
|
1660
|
+
* channel does not authenticate the person who pressed the button, and SPEC.md
|
|
1661
|
+
* §11's config-declared identity is what a review is recorded against. Anyone
|
|
1662
|
+
* who can reach the configured chat reviews as that actor, which is the same
|
|
1663
|
+
* trust boundary a tapped grant already stands on.
|
|
1664
|
+
*
|
|
1665
|
+
* The reaction is passed through untouched and NOTHING here reads it (SPEC.md
|
|
1666
|
+
* §11.1 invariant 10): it is a field on a record, chosen by a human, on its way
|
|
1667
|
+
* to the log.
|
|
1668
|
+
*/
|
|
1669
|
+
export function reviewHandlerFor(setup, streams) {
|
|
1670
|
+
return (tap) => {
|
|
1671
|
+
const result = reviewSample(setup.logPath, { kind: "seq", seq: tap.sampleSeq }, setup.actor, tap.note ?? null, {
|
|
1672
|
+
...(setup.gateOptions.policy === undefined ? {} : { policy: setup.gateOptions.policy }),
|
|
1673
|
+
verdict: tap.verdict,
|
|
1674
|
+
...(tap.reaction === undefined ? {} : { reaction: tap.reaction }),
|
|
1675
|
+
});
|
|
1676
|
+
if (!result.ok) {
|
|
1677
|
+
// SPEC.md §11.1 invariant 6: the code is machine-readable and distinct,
|
|
1678
|
+
// and it reaches the approver as itself. The card carries both halves —
|
|
1679
|
+
// the code on its own line, the message under it — because the codes that
|
|
1680
|
+
// get here are ones a reviewer can act on: `reaction-conflicts-verdict`
|
|
1681
|
+
// asks which half they meant, `note-required` asks for words.
|
|
1682
|
+
streams.err(`approval: telegram review refused (${result.code}): ${result.message}\n`);
|
|
1683
|
+
if (setup.json) {
|
|
1684
|
+
streams.out(`${JSON.stringify({
|
|
1685
|
+
event: "review",
|
|
1686
|
+
ok: false,
|
|
1687
|
+
sample_seq: tap.sampleSeq,
|
|
1688
|
+
verdict: tap.verdict,
|
|
1689
|
+
reaction: tap.reaction ?? null,
|
|
1690
|
+
code: result.code,
|
|
1691
|
+
})}\n`);
|
|
1692
|
+
}
|
|
1693
|
+
return {
|
|
1694
|
+
ok: false,
|
|
1695
|
+
headline: TELEGRAM_NOT_RECORDED,
|
|
1696
|
+
detail: [result.code, result.message],
|
|
1697
|
+
toast: "Not recorded — the card says why.",
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
const obligation = result.obligation;
|
|
1701
|
+
if (setup.json) {
|
|
1702
|
+
streams.out(`${JSON.stringify({
|
|
1703
|
+
event: "review",
|
|
1704
|
+
ok: true,
|
|
1705
|
+
seq: result.record.seq,
|
|
1706
|
+
sample_seq: result.subject.seq,
|
|
1707
|
+
action_key: result.subject.actionKey,
|
|
1708
|
+
verdict: tap.verdict,
|
|
1709
|
+
reaction: tap.reaction ?? null,
|
|
1710
|
+
obligation_seq: obligation === null ? null : obligation.seq,
|
|
1711
|
+
})}\n`);
|
|
1712
|
+
}
|
|
1713
|
+
else {
|
|
1714
|
+
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`);
|
|
1715
|
+
}
|
|
1716
|
+
const detail = [
|
|
1717
|
+
`recorded at seq ${String(result.record.seq)} by ${setup.actor} · verdict ${tap.verdict}`,
|
|
1718
|
+
...(tap.reaction === undefined
|
|
1719
|
+
? []
|
|
1720
|
+
: [`reaction: ${tap.reaction} — guidance, not policy; it changes no verdict and no budget`]),
|
|
1721
|
+
...(tap.note === undefined || tap.note.trim().length === 0
|
|
1722
|
+
? []
|
|
1723
|
+
: [`note: ${tap.note.trim()}`]),
|
|
1724
|
+
];
|
|
1725
|
+
if (obligation !== null) {
|
|
1726
|
+
// APRV-127's reconciliation, said on the reply: a denial cannot undo
|
|
1727
|
+
// anything, and what it DOES is open an obligation somebody has to
|
|
1728
|
+
// discharge. An approver who denied on a phone learns that here rather
|
|
1729
|
+
// than from a health surface later.
|
|
1730
|
+
const shape = String(obligation.payload?.["obligation"] ?? "");
|
|
1731
|
+
detail.push(shape === "gated-revert"
|
|
1732
|
+
? `reconciliation obligation at seq ${String(obligation.seq)}: GATED REVERT. The action was declared reversible, so undo it through the gate and close this with \`approval audit reconcile ${String(obligation.seq)} --revert <action-key> --note "…"\`.`
|
|
1733
|
+
: `reconciliation obligation at seq ${String(obligation.seq)}: POLICY FINDING. Nothing can be reverted, so what is owed is a review of the class that permitted this. Close it with \`approval audit reconcile ${String(obligation.seq)} --note "…"\` once that review has happened.`);
|
|
1734
|
+
}
|
|
1735
|
+
return {
|
|
1736
|
+
ok: true,
|
|
1737
|
+
headline: tap.verdict === "denied" ? TELEGRAM_REVIEW_DENIED : TELEGRAM_REVIEW_RECORDED,
|
|
1738
|
+
detail,
|
|
1739
|
+
toast: tap.verdict === "denied" ? "Recorded — denied." : "Recorded.",
|
|
1740
|
+
};
|
|
1741
|
+
};
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* `/queue`, `/skip`, `/next` — the paced walkthrough's three verbs (APRV-216).
|
|
1745
|
+
*
|
|
1746
|
+
* **None of them appends anything**, and the reason is structural rather than
|
|
1747
|
+
* careful: this function never touches `recordChannelDecision`, so there is no
|
|
1748
|
+
* path from a typed word to the log. A decision is a button, always, because a
|
|
1749
|
+
* button carries the nonce and the action reference that bind an answer to the
|
|
1750
|
+
* bytes an approver was shown, and a word typed into a chat carries neither.
|
|
1751
|
+
*
|
|
1752
|
+
* What they do move is process memory:
|
|
1753
|
+
*
|
|
1754
|
+
* - `/queue` reads the verified log and replies with the summary and a numbered
|
|
1755
|
+
* list. It changes nothing, and it works while a request is selected, because
|
|
1756
|
+
* the list is derived and not held. The reply says outright that it carries no
|
|
1757
|
+
* buttons and that it cannot vouch for a card it once sent (APRV-256).
|
|
1758
|
+
* - `/skip` sends the shown unit to the BACK of this process's order and
|
|
1759
|
+
* forgets having delivered it, so the next cycle shows the next question and
|
|
1760
|
+
* this one comes round again after the rest. The copy already in the chat
|
|
1761
|
+
* keeps its buttons, and they still decide the same request by action
|
|
1762
|
+
* reference (APRV-196), so a skip is "later", never "gone".
|
|
1763
|
+
* - `/next` releases the shown unit without reordering, so this process moves
|
|
1764
|
+
* past it and does not show it again. The same copy stays live in the chat:
|
|
1765
|
+
* the approver has kept the question and given up their place in the queue,
|
|
1766
|
+
* which is the opposite trade from `/skip`.
|
|
1767
|
+
*
|
|
1768
|
+
* A command that finds nothing to do says so, because silence in a chat window
|
|
1769
|
+
* is indistinguishable from a listener that has died.
|
|
1770
|
+
*/
|
|
1771
|
+
export function commandHandlerFor(setup, streams, state,
|
|
1772
|
+
/**
|
|
1773
|
+
* When the command arrived. A clock read in production, because a command is
|
|
1774
|
+
* answered when a human types it; injectable for the same reason
|
|
1775
|
+
* {@link dispatchPending} takes `now` as a parameter, since the ages a reply
|
|
1776
|
+
* states are arithmetic against it and a suite must be able to choose them.
|
|
1777
|
+
*/
|
|
1778
|
+
clock = () => new Date().toISOString()) {
|
|
1779
|
+
const say = async (lines) => {
|
|
1780
|
+
try {
|
|
1781
|
+
await setup.channel.announce(lines);
|
|
1782
|
+
}
|
|
1783
|
+
catch (cause) {
|
|
1784
|
+
streams.err(`approval: telegram could not answer a command: ${cause instanceof Error ? cause.message : String(cause)} — nothing was appended and the listener is still up\n`);
|
|
1785
|
+
}
|
|
1786
|
+
};
|
|
1787
|
+
return async (command) => {
|
|
1788
|
+
const now = clock();
|
|
1789
|
+
if (command === "queue") {
|
|
1790
|
+
const queue = buildPendingQueue(setup.logPath, setup.tagOptions, now);
|
|
1791
|
+
if (!queue.ok) {
|
|
1792
|
+
// SPEC.md §11.1(1): a sentence a human reads about what the log says is
|
|
1793
|
+
// derived from a log that verified, or it is not derived at all. So the
|
|
1794
|
+
// reply names the refusal rather than a queue nobody could derive.
|
|
1795
|
+
streams.err(`approval: telegram cannot read the pending queue for /queue (${queue.code}): ${queue.message}\n`);
|
|
1796
|
+
await say([
|
|
1797
|
+
"The queue could not be read.",
|
|
1798
|
+
`The log did not verify or could not be read (${queue.code}). Nothing is decided and nothing is lost; the listener retries every cycle.`,
|
|
1799
|
+
]);
|
|
1800
|
+
return;
|
|
1801
|
+
}
|
|
1802
|
+
// APRV-299. The retrospective backlog is part of what the log is holding,
|
|
1803
|
+
// and `/queue` is the verb that says what that is. Appended rather than
|
|
1804
|
+
// interleaved, because the two lists answer different questions: one is
|
|
1805
|
+
// what is waiting on the approver, the other what already happened.
|
|
1806
|
+
const lines = queueLines(queue.requests, now, state.paced.current ?? []);
|
|
1807
|
+
const built = openReviewCards(setup.logPath);
|
|
1808
|
+
if (built.ok && built.cards.length > 0)
|
|
1809
|
+
lines.push(...reviewSummaryLines(built.cards, now));
|
|
1810
|
+
await say(lines);
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
const shown = state.paced.current;
|
|
1814
|
+
if (shown === null) {
|
|
1815
|
+
// APRV-299. With no request selected, the two verbs act on the review
|
|
1816
|
+
// card if one is in front of the approver. Neither decides anything here
|
|
1817
|
+
// either: the sample stays open in the log whichever way it goes, and
|
|
1818
|
+
// `approval audit review <seq>` still names it from a terminal.
|
|
1819
|
+
const card = state.review.current;
|
|
1820
|
+
if (card !== null) {
|
|
1821
|
+
state.review.current = null;
|
|
1822
|
+
if (command === "skip") {
|
|
1823
|
+
state.review.delivered.delete(card);
|
|
1824
|
+
state.review.order = state.review.order.filter((seq) => seq !== card);
|
|
1825
|
+
state.review.order.push(card);
|
|
1826
|
+
}
|
|
1827
|
+
await say([
|
|
1828
|
+
command === "skip"
|
|
1829
|
+
? `Review of sample ${String(card)} skipped — it goes to the back of the order and a fresh card is sent once the rest have had their turn.`
|
|
1830
|
+
: `Review of sample ${String(card)} passed over — this listener sends no further card for it.`,
|
|
1831
|
+
"Nothing was recorded. The sample is still open: it is listed by `approval audit list` and reviewable with `approval audit review " +
|
|
1832
|
+
String(card) +
|
|
1833
|
+
"`, and the card already in this chat keeps its buttons.",
|
|
1834
|
+
]);
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
await say([
|
|
1838
|
+
// APRV-256: selection language, matching `/queue`'s. "In front of you"
|
|
1839
|
+
// was a claim about the approver's screen, which this process has never
|
|
1840
|
+
// been able to see.
|
|
1841
|
+
command === "skip"
|
|
1842
|
+
? "Nothing to skip — this listener has no request selected."
|
|
1843
|
+
: "This listener has no request selected right now.",
|
|
1844
|
+
"The next pending request is sent with its buttons on an upcoming cycle. /queue lists what the log is holding.",
|
|
1845
|
+
]);
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
state.paced.current = null;
|
|
1849
|
+
if (command === "skip") {
|
|
1850
|
+
for (const key of shown) {
|
|
1851
|
+
// Forgotten as well as reordered: the delivery bookkeeping is what stops
|
|
1852
|
+
// a re-send, so a request that must be SHOWN again has to leave it. The
|
|
1853
|
+
// message already in the chat is untouched and still decides.
|
|
1854
|
+
forget(state, key);
|
|
1855
|
+
state.paced.order = state.paced.order.filter((entry) => entry !== key);
|
|
1856
|
+
state.paced.order.push(key);
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
};
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* Start the dispatch-and-poll loop. **Installs no signal handler** and chooses
|
|
1863
|
+
* no exit code (APRV-110): both are the caller's, because `approval up` runs
|
|
1864
|
+
* this beside a daemon loop and a web server under one set of handlers.
|
|
1865
|
+
*
|
|
1866
|
+
* A FRESH {@link DispatchState} per call, which is the whole of the restart
|
|
1867
|
+
* story: a supervisor that restarts a fallen listener re-derives the pending
|
|
1868
|
+
* queue from the verified log and re-sends everything still pending, exactly as
|
|
1869
|
+
* a restarted process would. A duplicate on the phone, never a silence.
|
|
1870
|
+
*/
|
|
1871
|
+
export function startListener(setup, streams) {
|
|
1872
|
+
const { channel } = setup;
|
|
1873
|
+
channel.onDecision(handlerFor(setup, streams));
|
|
1874
|
+
// APRV-257. Registered unconditionally, because whether a checkpoint is ever
|
|
1875
|
+
// OFFERED is the policy's answer and a handler that exists for a prompt
|
|
1876
|
+
// nobody sends costs nothing. Registering it here is also what makes the
|
|
1877
|
+
// channel's `offerCheckpoint` legal at all: it refuses to send a button
|
|
1878
|
+
// nothing is listening for.
|
|
1879
|
+
channel.onCheckpoint(checkpointHandlerFor(setup, streams));
|
|
1880
|
+
// APRV-299. Registered unconditionally, for the same reason the checkpoint
|
|
1881
|
+
// handler is: whether a review card is ever OFFERED is the log's answer (an
|
|
1882
|
+
// open `audit.sampled`), and a handler that exists for a card nobody sends
|
|
1883
|
+
// costs nothing. Registering it is also what makes `offerReview` legal at
|
|
1884
|
+
// all, and what makes the channel read the `message` updates a note reply
|
|
1885
|
+
// arrives on.
|
|
1886
|
+
channel.onReview(reviewHandlerFor(setup, streams));
|
|
1887
|
+
// Delivery bookkeeping is in memory only — channels hold no state (SPEC.md
|
|
1888
|
+
// §10.3). A restarted listener therefore re-sends everything still pending.
|
|
1889
|
+
// Duplicated messages are the acceptable failure; a decision that depends on
|
|
1890
|
+
// a channel's memory surviving a crash is not. Since APRV-196 the duplicates
|
|
1891
|
+
// announce themselves (the banner this cycle sends) and the buttons on the
|
|
1892
|
+
// pre-restart copies still decide the same request, so what a restart costs
|
|
1893
|
+
// the approver is a longer transcript rather than a stuck one.
|
|
1894
|
+
const state = newDispatchState();
|
|
1895
|
+
// APRV-216. Registering the command handler is also what makes the channel
|
|
1896
|
+
// ask for `message` updates at all, so a burst listener consumes none and
|
|
1897
|
+
// `approval setup channel telegram`'s chat discovery is untouched by this
|
|
1898
|
+
// task. See `TelegramChannel.onCommand`.
|
|
1899
|
+
if (setup.delivery === "paced") {
|
|
1900
|
+
channel.onCommand(commandHandlerFor(setup, streams, state));
|
|
1901
|
+
}
|
|
1902
|
+
let stopping = false;
|
|
1903
|
+
const stop = () => {
|
|
1904
|
+
stopping = true;
|
|
1905
|
+
channel.stop();
|
|
1906
|
+
};
|
|
1907
|
+
const done = (async () => {
|
|
1908
|
+
// The startup cycle. Same call as every later one; only its *failures* are
|
|
1909
|
+
// treated differently, because an operator who has just mistyped a token or
|
|
1910
|
+
// pointed at an unreadable log should learn it at once rather than watch a
|
|
1911
|
+
// retry loop.
|
|
1912
|
+
const startup = await dispatchPending(setup, streams, state, new Date().toISOString());
|
|
1913
|
+
if (startup.queueError !== undefined) {
|
|
1914
|
+
return { kind: "queue-error", ...startup.queueError };
|
|
1915
|
+
}
|
|
1916
|
+
const firstFailure = startup.failed[0];
|
|
1917
|
+
if (firstFailure !== undefined) {
|
|
1918
|
+
return { kind: "send-failed", message: firstFailure.message };
|
|
1919
|
+
}
|
|
1920
|
+
// A stop that arrived during the startup cycle: `listen()` clears its own
|
|
1921
|
+
// stopped flag on entry, so a loop entered now would ignore it and block.
|
|
1922
|
+
if (stopping)
|
|
1923
|
+
return { kind: "stopped" };
|
|
1924
|
+
// Every subsequent cycle: re-derive, send what is new, complain and carry
|
|
1925
|
+
// on. Runs before each `getUpdates`, including the poll after a recovered
|
|
1926
|
+
// poll error, so a request appended mid-run is delivered without a restart.
|
|
1927
|
+
const beforePoll = async () => {
|
|
1928
|
+
reportCycle(await dispatchPending(setup, streams, state, new Date().toISOString()), streams);
|
|
1929
|
+
};
|
|
1930
|
+
await channel.listen(setup.once ? { once: true, beforePoll } : { beforePoll });
|
|
1931
|
+
if (setup.json) {
|
|
1932
|
+
streams.out(`${JSON.stringify({ event: "stopped", ...channel.stats() })}\n`);
|
|
1933
|
+
}
|
|
1934
|
+
return { kind: "stopped" };
|
|
1935
|
+
})();
|
|
1936
|
+
return { done, stop };
|
|
1937
|
+
}
|
|
1938
|
+
async function runListener(setup, streams) {
|
|
1939
|
+
const running = startListener(setup, streams);
|
|
1940
|
+
const stop = () => running.stop();
|
|
1941
|
+
process.on("SIGINT", stop);
|
|
1942
|
+
process.on("SIGTERM", stop);
|
|
1943
|
+
let outcome;
|
|
1944
|
+
try {
|
|
1945
|
+
outcome = await running.done;
|
|
1946
|
+
}
|
|
1947
|
+
finally {
|
|
1948
|
+
process.off("SIGINT", stop);
|
|
1949
|
+
process.off("SIGTERM", stop);
|
|
1950
|
+
}
|
|
1951
|
+
switch (outcome.kind) {
|
|
1952
|
+
case "stopped":
|
|
1953
|
+
return EXIT_OK;
|
|
1954
|
+
case "queue-error":
|
|
1955
|
+
return outcome.code === "log-unreadable"
|
|
1956
|
+
? ioError(streams, setup.json, outcome.message)
|
|
1957
|
+
: integrityError(streams, setup.json, outcome.message);
|
|
1958
|
+
case "send-failed":
|
|
1959
|
+
return ioError(streams, setup.json, `telegram sendMessage failed: ${outcome.message}`);
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
/**
|
|
1963
|
+
* The listener verb. Returns a promise, which is why `main` treats `channel`
|
|
1964
|
+
* specially: it is the only long-lived command in the CLI.
|
|
1965
|
+
*/
|
|
1966
|
+
export function commandTelegramListen(argv, streams, cwd) {
|
|
1967
|
+
const prepared = setUp(argv, streams, cwd);
|
|
1968
|
+
if (prepared.kind === "handled")
|
|
1969
|
+
return prepared.code;
|
|
1970
|
+
return runListener(prepared.setup, streams);
|
|
1971
|
+
}
|
|
1972
|
+
// ---------------------------------------------------------------------------
|
|
1973
|
+
// approval channel telegram health
|
|
1974
|
+
// ---------------------------------------------------------------------------
|
|
1975
|
+
/**
|
|
1976
|
+
* Configuration health, offline.
|
|
1977
|
+
*
|
|
1978
|
+
* It answers one question — "is this runtime configured to talk to Telegram?"
|
|
1979
|
+
* — and deliberately makes no network call: a health check that contacted the
|
|
1980
|
+
* Bot API would leak the existence of the bot from any shell, and would fail
|
|
1981
|
+
* for reasons (a captive portal, a rate limit) that say nothing about whether
|
|
1982
|
+
* the operator's configuration is right. The *live* counters (deliveries,
|
|
1983
|
+
* decisions, ignored callbacks, recovered poll errors) belong to a running
|
|
1984
|
+
* listener and are surfaced by `TelegramChannel.health()` / `stats()` in
|
|
1985
|
+
* process, and on the listener's stderr as they happen.
|
|
1986
|
+
*/
|
|
1987
|
+
export function commandTelegramHealth(argv, streams, cwd) {
|
|
1988
|
+
const json = argv.includes("--json");
|
|
1989
|
+
const parsed = parseFlags(argv, {
|
|
1990
|
+
"--policy": "string",
|
|
1991
|
+
"--dir": "string",
|
|
1992
|
+
"--json": "boolean",
|
|
1993
|
+
"--help": "boolean",
|
|
1994
|
+
"-h": "boolean",
|
|
1995
|
+
});
|
|
1996
|
+
if (!parsed.ok)
|
|
1997
|
+
return usageError(streams, json, parsed.message, TELEGRAM_HEALTH_HELP);
|
|
1998
|
+
if (boolFlag(parsed.flags, "--help") || boolFlag(parsed.flags, "-h")) {
|
|
1999
|
+
streams.out(`${TELEGRAM_HEALTH_HELP}\n`);
|
|
2000
|
+
return EXIT_OK;
|
|
2001
|
+
}
|
|
2002
|
+
// Which variables to look at is a policy question (§5.1), so this offline
|
|
2003
|
+
// check reads the policy for the NAMES — and only the names. It still makes
|
|
2004
|
+
// no network call, and an unloadable policy leaves the defaults in force
|
|
2005
|
+
// rather than reporting a channel that cannot be configured at all.
|
|
2006
|
+
const policyFlag = stringFlag(parsed.flags, "--policy");
|
|
2007
|
+
const dirFlag = stringFlag(parsed.flags, "--dir");
|
|
2008
|
+
const policyLoad = loadPolicy(policyFlag !== null
|
|
2009
|
+
? { file: absolute(policyFlag, cwd) }
|
|
2010
|
+
: { dir: dirFlag === null ? cwd : absolute(dirFlag, cwd) });
|
|
2011
|
+
const tokenEnv = telegramTokenEnvFor(policyLoad);
|
|
2012
|
+
const chatEnv = telegramChatEnvFor(policyLoad);
|
|
2013
|
+
const token = env(tokenEnv);
|
|
2014
|
+
const chatId = env(chatEnv);
|
|
2015
|
+
const ok = token !== null && chatId !== null;
|
|
2016
|
+
if (json) {
|
|
2017
|
+
streams.out(`${JSON.stringify({
|
|
2018
|
+
ok,
|
|
2019
|
+
channel: "telegram",
|
|
2020
|
+
// Presence only. The token's value never appears in any output.
|
|
2021
|
+
token_env: tokenEnv,
|
|
2022
|
+
token_set: token !== null,
|
|
2023
|
+
chat_env: chatEnv,
|
|
2024
|
+
chat_id: chatId,
|
|
2025
|
+
})}\n`);
|
|
2026
|
+
}
|
|
2027
|
+
else if (ok) {
|
|
2028
|
+
streams.out(`telegram: configured (${tokenEnv} set, chat ${String(chatId)})\n`);
|
|
2029
|
+
}
|
|
2030
|
+
else {
|
|
2031
|
+
streams.err(`approval: telegram is not configured: ${[
|
|
2032
|
+
token === null ? tokenEnv : null,
|
|
2033
|
+
chatId === null ? chatEnv : null,
|
|
2034
|
+
]
|
|
2035
|
+
.filter((name) => name !== null)
|
|
2036
|
+
.join(" and ")} unset or empty\n`);
|
|
2037
|
+
}
|
|
2038
|
+
return ok ? EXIT_OK : EXIT_INTEGRITY;
|
|
2039
|
+
}
|
|
2040
|
+
// ---------------------------------------------------------------------------
|
|
2041
|
+
// Dispatch
|
|
2042
|
+
// ---------------------------------------------------------------------------
|
|
2043
|
+
export function commandTelegram(argv, streams, cwd) {
|
|
2044
|
+
const sub = argv[0];
|
|
2045
|
+
const rest = argv.slice(1);
|
|
2046
|
+
const json = argv.includes("--json");
|
|
2047
|
+
if (sub === undefined) {
|
|
2048
|
+
return usageError(streams, json, "missing subcommand for `approval channel telegram`", TELEGRAM_HELP);
|
|
2049
|
+
}
|
|
2050
|
+
if (sub === "--help" || sub === "-h" || sub === "help") {
|
|
2051
|
+
streams.out(`${TELEGRAM_HELP}\n`);
|
|
2052
|
+
return EXIT_OK;
|
|
2053
|
+
}
|
|
2054
|
+
switch (sub) {
|
|
2055
|
+
case "listen":
|
|
2056
|
+
return commandTelegramListen(rest, streams, cwd);
|
|
2057
|
+
case "health":
|
|
2058
|
+
return commandTelegramHealth(rest, streams, cwd);
|
|
2059
|
+
default:
|
|
2060
|
+
return usageError(streams, json, `unknown subcommand ${JSON.stringify(sub)} for \`approval channel telegram\``, TELEGRAM_HELP);
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
//# sourceMappingURL=channel-telegram.js.map
|