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,1988 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `approvald` — the daemon loop of SPEC.md §10.2 (APRV-39).
|
|
3
|
+
*
|
|
4
|
+
* > "`approvald` watches the backlog folder and the log: validates new/changed
|
|
5
|
+
* > envelopes, applies policy, dispatches channel notifications, expires TTLs,
|
|
6
|
+
* > samples supervised actions for audit, re-renders projections, and
|
|
7
|
+
* > (optionally) polls upstream sources."
|
|
8
|
+
*
|
|
9
|
+
* This module is the loop's core: watch, envelope drift, TTL sweep, projection
|
|
10
|
+
* write-back, queue regeneration, and loop-escalation surfacing. Channel dispatch
|
|
11
|
+
* belongs to the channel verbs (APRV-23/25/26), audit sampling to APRV-40, and
|
|
12
|
+
* payload-retention pruning to APRV-41; each is its own task and none of them is
|
|
13
|
+
* smuggled in here.
|
|
14
|
+
*
|
|
15
|
+
* ## Drift, then repair (SPEC.md §6.3, §10.2, APRV-62)
|
|
16
|
+
*
|
|
17
|
+
* A task file's `state:` is a projection and the log is the truth, so the two
|
|
18
|
+
* halves of that sentence are two steps of one tick. The drift scan runs first
|
|
19
|
+
* and appends `envelope.drift` for every file whose claim the log contradicts;
|
|
20
|
+
* the write-back pass runs after every append this tick could make and rewrites
|
|
21
|
+
* those files through `core/task-file.ts` so the projection matches the log
|
|
22
|
+
* again. Write-back never appends and never precedes an append: it only copies a
|
|
23
|
+
* fact the log already carries into a file that disagreed with it.
|
|
24
|
+
*
|
|
25
|
+
* So a drift record marks the moment a file was found wrong **and fixed**, not a
|
|
26
|
+
* standing disagreement. That reading is what makes the records worth watching:
|
|
27
|
+
* a file that keeps drifting after repair is a file some other writer is fighting
|
|
28
|
+
* the daemon over, and the repeated records are how an operator sees it. One
|
|
29
|
+
* record per transition is the healthy shape (the log moved, the file caught up);
|
|
30
|
+
* a run of identical records against an unmoving log is not.
|
|
31
|
+
*
|
|
32
|
+
* ## It decides nothing of its own
|
|
33
|
+
*
|
|
34
|
+
* Every judgement the daemon makes is one some other module already owns:
|
|
35
|
+
*
|
|
36
|
+
* - approval state per action is `core/state.ts`'s `requestState`, rolled up to
|
|
37
|
+
* the task by `daemon/projection.ts` and never re-derived;
|
|
38
|
+
* - expiry is `core/gate.ts`'s `expire`, the system verb, which re-reads the log,
|
|
39
|
+
* re-judges the TTL, and refuses anything that is not a live lapsed request;
|
|
40
|
+
* - the queue is `channels/render-queue.ts`'s `writeQueue`, the same renderer
|
|
41
|
+
* `approval render` calls, writing the same file the same atomic way;
|
|
42
|
+
* - loop escalation is `core/loop.ts`'s projection, which the gate and the
|
|
43
|
+
* executor already enforce. The daemon **surfaces** it and enforces nothing.
|
|
44
|
+
*
|
|
45
|
+
* What is new here is scheduling: when to look, how often, and how not to append
|
|
46
|
+
* the same fact twice.
|
|
47
|
+
*
|
|
48
|
+
* ## Watching, and why correctness never depends on it
|
|
49
|
+
*
|
|
50
|
+
* `fs.watch` is bursty, coalescing, and platform-dependent: one editor save can
|
|
51
|
+
* produce three events or one, a rename can arrive as a delete plus a create,
|
|
52
|
+
* and on some filesystems nothing arrives at all. So the watcher is treated as a
|
|
53
|
+
* **latency optimization and nothing else**. Every tick re-scans the task folder
|
|
54
|
+
* and re-derives everything from the verified log, and a periodic tick runs on
|
|
55
|
+
* `intervalMs` whether or not any watcher ever fires. A daemon whose watchers all
|
|
56
|
+
* failed to attach is a slower daemon, never a wrong one — which is also what
|
|
57
|
+
* makes the behavior testable without depending on any platform's watch
|
|
58
|
+
* semantics.
|
|
59
|
+
*
|
|
60
|
+
* Watch events are debounced (`debounceMs`) so a burst collapses into one tick.
|
|
61
|
+
* Ticks are synchronous end to end, so they cannot interleave.
|
|
62
|
+
*
|
|
63
|
+
* Which of those events are acted on, and which are dropped as this loop's own
|
|
64
|
+
* hand or as bookkeeping nobody reads, is {@link Daemon.attachWatchers}.
|
|
65
|
+
* `traceWatch` prints the decision for every event, and the `tick` line's
|
|
66
|
+
* `woke_by` names the one that opened the window it closed (APRV-230): a tick is
|
|
67
|
+
* the daemon's unit of work, and one nobody asked for costs exactly as much as
|
|
68
|
+
* one somebody did.
|
|
69
|
+
*
|
|
70
|
+
* ## Single writer, in intent only
|
|
71
|
+
*
|
|
72
|
+
* CLAUDE.md's rule is that the daemon is the sole writer while it runs. That is
|
|
73
|
+
* an operational stance, not a lock this module takes: the CLI verbs remain
|
|
74
|
+
* appendable at any moment, `core/log.ts`'s advisory lockfile serializes the
|
|
75
|
+
* writes, and every append here passes `expectedHead` so a check made against one
|
|
76
|
+
* log cannot land on another (compare-and-append, SPEC.md §11.1 invariant 5).
|
|
77
|
+
*
|
|
78
|
+
* The daemon therefore **tolerates external appends by re-reading**: a
|
|
79
|
+
* `head-moved` refusal is reported and dropped, never retried in place, because
|
|
80
|
+
* the next tick re-derives the whole question from the log as it now is. It holds
|
|
81
|
+
* no lock of its own and leaves no lockfile behind — the only lockfile in this
|
|
82
|
+
* system is the one `appendEvent` creates and releases inside a single call.
|
|
83
|
+
*
|
|
84
|
+
* ## Fail closed, loudly
|
|
85
|
+
*
|
|
86
|
+
* A log that does not verify stops the daemon rather than degrading it. Nothing
|
|
87
|
+
* may be appended onto a chain that does not verify, projections built from one
|
|
88
|
+
* would be screenshots of something nobody should read, and a daemon that kept
|
|
89
|
+
* running while reporting corruption would train an operator to ignore it.
|
|
90
|
+
*/
|
|
91
|
+
import { watch } from "node:fs";
|
|
92
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
93
|
+
import { basename, dirname, isAbsolute, join, relative } from "node:path";
|
|
94
|
+
import { writeQueue } from "../channels/render-queue.js";
|
|
95
|
+
import { tick as readClock } from "../core/clock.js";
|
|
96
|
+
import { parseFrontmatter, readTaskFile } from "../core/frontmatter.js";
|
|
97
|
+
import { expire } from "../core/gate.js";
|
|
98
|
+
import { appendEvent } from "../core/log.js";
|
|
99
|
+
import { loopEscalation } from "../core/loop.js";
|
|
100
|
+
import { payloadHash } from "../core/payload.js";
|
|
101
|
+
import { loadPolicy } from "../core/policy-load.js";
|
|
102
|
+
import { rewriteTaskFile, writeTaskFileAtomic } from "../core/task-file.js";
|
|
103
|
+
import { processReadCache, readVerifiedRecords, } from "../core/state.js";
|
|
104
|
+
import { validate } from "../core/validate.js";
|
|
105
|
+
import { publishedState } from "../cli/log-advance.js";
|
|
106
|
+
import { isAdvanceBookkeeping } from "../core/advance-cycle.js";
|
|
107
|
+
import { repoRoot } from "../cli/git-scope.js";
|
|
108
|
+
import { checkLogAnchor, resolveAnchor } from "../cli/log-anchor.js";
|
|
109
|
+
import { checkLogCheckpoints, checkpointPolicyOf, } from "../core/checkpoint.js";
|
|
110
|
+
import { authorizeAdvance, proveDanglingAdvancesFor, runAdvanceAsync, runAdvanceSync, settleAdvanceFinish, sweepDanglingAdvances, sweepIsClear, unreconciledRefusal, } from "./advance.js";
|
|
111
|
+
import { sweepAuditSampling } from "./audit.js";
|
|
112
|
+
import { sweepDarkSessions, } from "./dark-session.js";
|
|
113
|
+
import { prunePayloads } from "./prune.js";
|
|
114
|
+
import { driftAlreadyLogged, lapsedRequests, latestRegistration, taskEnvelopeState, } from "./projection.js";
|
|
115
|
+
/**
|
|
116
|
+
* SPEC.md §8: runtime-originated events carry a `system:` actor. The daemon is
|
|
117
|
+
* the runtime, and `envelope.drift` is its own event — distinct from
|
|
118
|
+
* `system:gate`, which `core/gate.ts` stamps on the expiries it appends, so a
|
|
119
|
+
* reader can tell which part of the runtime spoke.
|
|
120
|
+
*/
|
|
121
|
+
export const DAEMON_ACTOR = "system:daemon";
|
|
122
|
+
/** Backlog.md's conventional task folder, relative to the working directory. */
|
|
123
|
+
export { DEFAULT_TASKS_DIR } from "../core/registration.js";
|
|
124
|
+
/** How often the daemon looks, absent any watcher event. */
|
|
125
|
+
export const DEFAULT_INTERVAL_MS = 30_000;
|
|
126
|
+
/** How long a burst of watcher events is allowed to settle before a tick. */
|
|
127
|
+
export const DEFAULT_DEBOUNCE_MS = 250;
|
|
128
|
+
/**
|
|
129
|
+
* Why a watcher event did not schedule a tick (APRV-230).
|
|
130
|
+
*
|
|
131
|
+
* Machine-readable and a closed union, for the reason {@link
|
|
132
|
+
* DAEMON_WARNING_CODES} is: an operator counting phantom ticks groups the trace
|
|
133
|
+
* by this field, and a free-text sentence would make that a grep instead of a
|
|
134
|
+
* count.
|
|
135
|
+
*/
|
|
136
|
+
export const WATCH_IGNORE_REASONS = [
|
|
137
|
+
/** A task file this daemon's own write-back pass placed (APRV-211). */
|
|
138
|
+
"self-write",
|
|
139
|
+
/** The `.<name>.tmp-<pid>-<n>` file this process writes a task file through. */
|
|
140
|
+
"own-temp",
|
|
141
|
+
/** A bookkeeping file: see {@link bookkeepingKind}. */
|
|
142
|
+
"bookkeeping",
|
|
143
|
+
/** Something in the log's directory that is not the log (the snapshot). */
|
|
144
|
+
"not-the-log",
|
|
145
|
+
];
|
|
146
|
+
/**
|
|
147
|
+
* What kind of bookkeeping file `name` is, or `null` when it is not one.
|
|
148
|
+
*
|
|
149
|
+
* The files that live in these two directories without ever being their
|
|
150
|
+
* subject: the append lockfile every writer in this runtime creates and removes
|
|
151
|
+
* (`<log>.lock`, `core/log.ts`), the swap, autosave, backup and lock files an
|
|
152
|
+
* editor scatters beside a file it is editing, and macOS's own `.DS_Store` and
|
|
153
|
+
* AppleDouble residue. Each of these is a filesystem event about how a change
|
|
154
|
+
* was made and never about the change; a tick scheduled for one re-derives an
|
|
155
|
+
* answer nothing has moved (APRV-230, and the lockfile pair is two events per
|
|
156
|
+
* append from every writer on the machine).
|
|
157
|
+
*
|
|
158
|
+
* Safe for exactly the reason {@link Daemon.attachWatchers} documents: the
|
|
159
|
+
* watcher is a latency optimization and correctness never depended on it
|
|
160
|
+
* (SPEC.md §10.2), so the worst an over-eager name here can cost is one
|
|
161
|
+
* `--interval` of latency. Nothing in this list can name a Backlog.md task file,
|
|
162
|
+
* which is `<id> - <slug>.md`.
|
|
163
|
+
*/
|
|
164
|
+
export function bookkeepingKind(name) {
|
|
165
|
+
// The append lockfile, and any other `.lock` a tool leaves in these folders.
|
|
166
|
+
if (name.endsWith(".lock"))
|
|
167
|
+
return "lockfile";
|
|
168
|
+
// vim: `.task-042.md.swp`, `.task-042.md.swo`, …
|
|
169
|
+
if (/\.sw[a-p]$/u.test(name))
|
|
170
|
+
return "swap";
|
|
171
|
+
// emacs: `#task-042.md#` (autosave) and `.#task-042.md` (the lock symlink).
|
|
172
|
+
if (/^#.+#$/u.test(name) || name.startsWith(".#"))
|
|
173
|
+
return "editor-lock";
|
|
174
|
+
// Backups: `task-042.md~`, and JetBrains' write-through temporaries.
|
|
175
|
+
if (name.endsWith("~") || /___jb_(?:tmp|old)___$/u.test(name))
|
|
176
|
+
return "backup";
|
|
177
|
+
// macOS: the Finder's own index, and AppleDouble sidecars.
|
|
178
|
+
if (name === ".DS_Store" || name.startsWith("._"))
|
|
179
|
+
return "macos";
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Why the daemon complained without stopping. Machine-readable and distinct, per
|
|
184
|
+
* SPEC.md §11.1 invariant 6, and a closed union for the same reason the gate's
|
|
185
|
+
* refusal codes are: a supervisor that branches on them needs them stable.
|
|
186
|
+
*/
|
|
187
|
+
export const DAEMON_WARNING_CODES = [
|
|
188
|
+
/** A task file could not be read (permissions, a vanished file). */
|
|
189
|
+
"task-unreadable",
|
|
190
|
+
/** A task file's frontmatter does not parse. */
|
|
191
|
+
"frontmatter-invalid",
|
|
192
|
+
/** The `approval:` envelope failed `envelope.schema.json`. */
|
|
193
|
+
"envelope-invalid",
|
|
194
|
+
/** The frontmatter carries no usable `id`, so no drift can be keyed to it. */
|
|
195
|
+
"task-id-missing",
|
|
196
|
+
/** The task folder could not be listed; the next tick tries again. */
|
|
197
|
+
"tasks-dir-unreadable",
|
|
198
|
+
/** An append was refused. The next tick re-derives and may try again. */
|
|
199
|
+
"append-refused",
|
|
200
|
+
/** The TTL sweep's `expire` refused for a reason other than a race. */
|
|
201
|
+
"expire-refused",
|
|
202
|
+
/** The queue could not be written. The log is untouched. */
|
|
203
|
+
"render-failed",
|
|
204
|
+
/** A watcher could not attach; the periodic tick covers the folder anyway. */
|
|
205
|
+
"watch-unavailable",
|
|
206
|
+
/**
|
|
207
|
+
* A payload-retention prune did not complete (APRV-41). The store keeps the
|
|
208
|
+
* file, and the next tick re-derives; nothing is ever deleted unlogged.
|
|
209
|
+
*/
|
|
210
|
+
"prune-refused",
|
|
211
|
+
/**
|
|
212
|
+
* The projection write-back was refused by the writer, or the rewritten bytes
|
|
213
|
+
* could not be placed (APRV-62). The file is left exactly as it was and the
|
|
214
|
+
* log is untouched; the message carries `core/task-file.ts`'s own code.
|
|
215
|
+
*/
|
|
216
|
+
"write-back-refused",
|
|
217
|
+
/**
|
|
218
|
+
* A cadence advance did not publish (APRV-204): the gate sent it to a human,
|
|
219
|
+
* refused it, or the verb itself failed. Nothing was committed, the outcome
|
|
220
|
+
* is on the `advance` line beside this warning, and the next tick tries
|
|
221
|
+
* again — the cadence interval is the retry bound, so there is no hot loop.
|
|
222
|
+
*/
|
|
223
|
+
"advance-refused",
|
|
224
|
+
/**
|
|
225
|
+
* A dark-session sweep (APRV-192) found git activity it could not judge, or
|
|
226
|
+
* could not append the observation it did reach. Uncertainty is reported as
|
|
227
|
+
* uncertainty and never as a pass; nothing is escalated on it, because a
|
|
228
|
+
* detector reports and the gate decides.
|
|
229
|
+
*/
|
|
230
|
+
"dark-session-undetermined",
|
|
231
|
+
/**
|
|
232
|
+
* The working log is a strict PREFIX of its committed copy (APRV-219): the
|
|
233
|
+
* anchor carries records this file does not. Not a divergence — the two are
|
|
234
|
+
* one chain and this checkout is behind it — and not silence either, because
|
|
235
|
+
* an append onto the shorter chain forks it. `approval log sync` is the
|
|
236
|
+
* repair, and the next tick asks again.
|
|
237
|
+
*/
|
|
238
|
+
"anchor-behind",
|
|
239
|
+
/**
|
|
240
|
+
* `audit.checkpoint_every` says a human-signed checkpoint is due and the log
|
|
241
|
+
* carries none that recent (APRV-220). A WARNING and never a stop, at every
|
|
242
|
+
* layer: a human who has been away is not a forger, and a daemon that stopped
|
|
243
|
+
* for want of a tap is a daemon whose operator turns the check off.
|
|
244
|
+
*/
|
|
245
|
+
"checkpoint-due",
|
|
246
|
+
/**
|
|
247
|
+
* The live-draw socket could not be served (APRV-208). Nothing is degraded:
|
|
248
|
+
* every asker fails closed to a human decision, which is exactly what happens
|
|
249
|
+
* on a machine where no daemon runs at all. It is a warning rather than a
|
|
250
|
+
* silence because the operator's `supervised-live` classes are gating at 100%
|
|
251
|
+
* while it stands, and that is a thing to know rather than to discover from a
|
|
252
|
+
* month of taps.
|
|
253
|
+
*/
|
|
254
|
+
"draw-unavailable",
|
|
255
|
+
];
|
|
256
|
+
function errorMessage(cause) {
|
|
257
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* The Backlog.md board key a task file's name begins with (`task-3 - Slug.md`).
|
|
261
|
+
*
|
|
262
|
+
* Used for one question only (APRV-63): a file that has lost its frontmatter
|
|
263
|
+
* entirely leaves no id anywhere, so the name is the only handle left with
|
|
264
|
+
* which to ask the log whether this task ever registered anything. The answer,
|
|
265
|
+
* and the id every record is written under, come from the log.
|
|
266
|
+
*/
|
|
267
|
+
function taskIdFromFileName(path) {
|
|
268
|
+
const match = /^([A-Za-z][A-Za-z0-9_]*-\d+)/u.exec(basename(path));
|
|
269
|
+
return match?.[1] ?? null;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The daemon.
|
|
273
|
+
*
|
|
274
|
+
* One instance owns one working set (a log, a task folder, a queue file) and one
|
|
275
|
+
* set of timers. {@link run} resolves when the loop stops; {@link stop} is what a
|
|
276
|
+
* signal handler calls.
|
|
277
|
+
*/
|
|
278
|
+
export class Daemon {
|
|
279
|
+
options;
|
|
280
|
+
watchers = [];
|
|
281
|
+
watchAttempted = false;
|
|
282
|
+
watching = false;
|
|
283
|
+
interval = null;
|
|
284
|
+
debounce = null;
|
|
285
|
+
ticking = false;
|
|
286
|
+
ticks = 0;
|
|
287
|
+
drifts = 0;
|
|
288
|
+
expiries = 0;
|
|
289
|
+
renders = 0;
|
|
290
|
+
lastRender = null;
|
|
291
|
+
/** Epoch ms of the last advance ATTEMPT, refusals included (APRV-204). */
|
|
292
|
+
lastAdvanceAt = null;
|
|
293
|
+
lastAdvance = null;
|
|
294
|
+
/** How many substantive records were owed at the last attempt. */
|
|
295
|
+
lastAdvanceOwed = null;
|
|
296
|
+
/**
|
|
297
|
+
* Where the owed span ENDED at the last attempt (APRV-233).
|
|
298
|
+
*
|
|
299
|
+
* The count trigger measures against this inside the interval, so records an
|
|
300
|
+
* attempt has already tried to publish are not counted a second time towards
|
|
301
|
+
* publishing them again.
|
|
302
|
+
*/
|
|
303
|
+
lastAdvanceSpanEnd = null;
|
|
304
|
+
/**
|
|
305
|
+
* The advance whose git work is still running in a child (APRV-211).
|
|
306
|
+
*
|
|
307
|
+
* One slot, and a tick that finds it taken makes no attempt at all: two
|
|
308
|
+
* advances against one log would race for the append lock and for the records
|
|
309
|
+
* branch, and the second would have nothing to publish anyway.
|
|
310
|
+
*/
|
|
311
|
+
advanceInFlight = null;
|
|
312
|
+
/**
|
|
313
|
+
* An advance outcome this process observed and could not record (APRV-233).
|
|
314
|
+
*
|
|
315
|
+
* The 2026-09-02 residue: a hook's record landed between `recordFinish`'s
|
|
316
|
+
* read and its append, the bounded retry was spent, and the execution stayed
|
|
317
|
+
* open. The outcome is a fact this process holds and the log does not, so it
|
|
318
|
+
* is carried here and settled at the top of the next tick, before any trigger
|
|
319
|
+
* is looked at. Nothing else may advance while it stands.
|
|
320
|
+
*/
|
|
321
|
+
pendingAdvanceFinish = null;
|
|
322
|
+
/**
|
|
323
|
+
* The dangling advance cycles this process has already reported (APRV-264).
|
|
324
|
+
*
|
|
325
|
+
* A SET rather than one key: on 2026-09-05 five of them stood at once, and a
|
|
326
|
+
* single slot meant the operator was told about one, then about the next only
|
|
327
|
+
* after they had closed the first by hand. Reported once per key — the
|
|
328
|
+
* started line counts as the report for anything the startup listing found,
|
|
329
|
+
* so the first tick does not say it a second time — and never once per tick,
|
|
330
|
+
* because a warning printed every thirty seconds forever stops being read.
|
|
331
|
+
*/
|
|
332
|
+
reportedDangling = new Set();
|
|
333
|
+
/** Epoch ms of the last dark-session sweep (APRV-192); `null` before the first. */
|
|
334
|
+
lastDarkSweepAt = null;
|
|
335
|
+
reportedEscalations = new Set();
|
|
336
|
+
/** Verified reads made during the current tick (APRV-211). Reset at tick start. */
|
|
337
|
+
reads = 0;
|
|
338
|
+
/** Did any of this tick's own reads hash the whole prefix (APRV-217)? */
|
|
339
|
+
fullReproofThisTick = false;
|
|
340
|
+
/** This tick's anchor comparison (APRV-219), or `null` when it made none. */
|
|
341
|
+
anchorThisTick = null;
|
|
342
|
+
/** This tick's checkpoint check (APRV-220), or `null` when it made none. */
|
|
343
|
+
checkpointsThisTick = null;
|
|
344
|
+
/**
|
|
345
|
+
* The watcher event that opened the current debounce window (APRV-230), or
|
|
346
|
+
* `null` when nothing has woken this daemon since the last tick consumed it.
|
|
347
|
+
* Read once at the top of a tick and cleared there, so a periodic tick that
|
|
348
|
+
* happens to run first takes the attribution with the work.
|
|
349
|
+
*/
|
|
350
|
+
pendingWake = null;
|
|
351
|
+
/** Basenames {@link writeBack} placed this tick, so the watcher can ignore them. */
|
|
352
|
+
selfWrites = new Set();
|
|
353
|
+
/** The previous tick's, kept one generation: watch events arrive after the write. */
|
|
354
|
+
previousSelfWrites = new Set();
|
|
355
|
+
settle = null;
|
|
356
|
+
finished = false;
|
|
357
|
+
/** Whether {@link DaemonOptions.draw} actually bound (APRV-208). */
|
|
358
|
+
drawServing = false;
|
|
359
|
+
constructor(options) {
|
|
360
|
+
this.options = options;
|
|
361
|
+
}
|
|
362
|
+
/** Run until stopped (or, with `once`, for exactly one tick). */
|
|
363
|
+
run() {
|
|
364
|
+
return new Promise((resolve) => {
|
|
365
|
+
this.settle = resolve;
|
|
366
|
+
// The cadence clock starts when the daemon does (APRV-204), so a restart
|
|
367
|
+
// does not publish a records branch the moment it comes up: a daemon
|
|
368
|
+
// restarted in a loop would otherwise open a pull request per restart.
|
|
369
|
+
// The RECORD-COUNT trigger is unaffected, which is what keeps a busy
|
|
370
|
+
// repository from waiting out an interval it does not need to.
|
|
371
|
+
const started = Date.parse(readClock(this.clockOptions()));
|
|
372
|
+
this.lastAdvanceAt = Number.isNaN(started) ? 0 : started;
|
|
373
|
+
if (!this.options.once)
|
|
374
|
+
this.attachWatchers();
|
|
375
|
+
// APRV-208. Before the `started` line, so that line can say truthfully
|
|
376
|
+
// whether this run is answering draws. A refusal is reported and the loop
|
|
377
|
+
// continues: askers fail closed to a human, which is where they were
|
|
378
|
+
// going with no daemon at all.
|
|
379
|
+
let drawPath = null;
|
|
380
|
+
if (this.options.draw !== undefined) {
|
|
381
|
+
const served = this.options.draw.start();
|
|
382
|
+
if (served.ok) {
|
|
383
|
+
drawPath = served.path;
|
|
384
|
+
this.drawServing = true;
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
this.warn("draw-unavailable", `live draws are not being served (${served.reason}): ${served.detail}. Every supervised-live action gates to a human until this is fixed.`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
this.emit({
|
|
391
|
+
event: "started",
|
|
392
|
+
log: this.display(this.options.logPath),
|
|
393
|
+
tasks: this.display(this.options.tasksDir),
|
|
394
|
+
queue: this.display(this.options.queuePath),
|
|
395
|
+
interval_ms: this.options.intervalMs,
|
|
396
|
+
debounce_ms: this.options.debounceMs,
|
|
397
|
+
watching: this.watching,
|
|
398
|
+
read_proof: this.options.readProof?.mode ?? "full",
|
|
399
|
+
// APRV-219. RESOLVED here and COMPARED by the first tick, which runs a
|
|
400
|
+
// line below and always re-proves in full: the started line's job is to
|
|
401
|
+
// name the witness this run holds itself to, and resolving it costs two
|
|
402
|
+
// git reads and no log read at all.
|
|
403
|
+
anchor: this.resolveAnchorForReport(),
|
|
404
|
+
draw: drawPath,
|
|
405
|
+
// APRV-264. LISTED here, SWEPT by the first tick, which runs a line
|
|
406
|
+
// below: the started line's job is to name what is blocking the
|
|
407
|
+
// cadence, and listing costs one verified read and one git read where
|
|
408
|
+
// appending five outcome records before the process has said hello
|
|
409
|
+
// would be a write nobody asked for yet. Seeded into `reportedDangling`
|
|
410
|
+
// so the tick that immediately follows does not repeat it.
|
|
411
|
+
dangling_advances: this.listDanglingAdvancesAtStartup(),
|
|
412
|
+
});
|
|
413
|
+
const outcome = this.tick();
|
|
414
|
+
if (outcome !== null) {
|
|
415
|
+
this.finish(outcome);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (this.options.once) {
|
|
419
|
+
this.finish({ kind: "stopped", reason: "once" });
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
this.interval = setInterval(() => {
|
|
423
|
+
const periodic = this.tick();
|
|
424
|
+
if (periodic !== null)
|
|
425
|
+
this.finish(periodic);
|
|
426
|
+
}, this.options.intervalMs);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
/** Stop cleanly: timers cleared, watchers closed, nothing half-written. */
|
|
430
|
+
stop(reason) {
|
|
431
|
+
this.finish({ kind: "stopped", reason });
|
|
432
|
+
}
|
|
433
|
+
// -------------------------------------------------------------------------
|
|
434
|
+
// Scheduling
|
|
435
|
+
// -------------------------------------------------------------------------
|
|
436
|
+
finish(outcome) {
|
|
437
|
+
if (this.finished)
|
|
438
|
+
return;
|
|
439
|
+
this.finished = true;
|
|
440
|
+
if (this.interval !== null)
|
|
441
|
+
clearInterval(this.interval);
|
|
442
|
+
this.interval = null;
|
|
443
|
+
if (this.debounce !== null)
|
|
444
|
+
clearTimeout(this.debounce);
|
|
445
|
+
this.debounce = null;
|
|
446
|
+
for (const watcher of this.watchers) {
|
|
447
|
+
try {
|
|
448
|
+
watcher.close();
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
// A watcher that is already closed, or whose directory vanished, has
|
|
452
|
+
// nothing left to release. Shutdown must not fail on it.
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
this.watchers.length = 0;
|
|
456
|
+
// APRV-208. The socket goes with the process that served it: a socket file
|
|
457
|
+
// outliving its server is a hook connecting to nothing, which is a slower
|
|
458
|
+
// road to the same gated verdict but a confusing one. Closed before the
|
|
459
|
+
// shutdown flush, because nothing in the flush answers draws.
|
|
460
|
+
if (this.drawServing && this.options.draw !== undefined) {
|
|
461
|
+
this.drawServing = false;
|
|
462
|
+
try {
|
|
463
|
+
this.options.draw.close();
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
// A server that will not close cannot stop the daemon from stopping.
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// The shutdown flush (APRV-204). A clean stop with unpublished records
|
|
470
|
+
// publishes them before it goes: the daemon is the log's writer, and a
|
|
471
|
+
// writer that exits leaving hours of records on nobody's branch hands the
|
|
472
|
+
// problem back to whoever has to remember. Only on a CLEAN stop — the three
|
|
473
|
+
// failure outcomes are all "this log is not fit to be committed from", and
|
|
474
|
+
// the advance would refuse them a second time anyway.
|
|
475
|
+
//
|
|
476
|
+
// Synchronous, inside the shutdown path, before the `stopped` line: the
|
|
477
|
+
// advance is `spawnSync` throughout, so there is nothing to await and
|
|
478
|
+
// nothing that could outlive the process.
|
|
479
|
+
if (outcome.kind === "stopped") {
|
|
480
|
+
// An advance whose child is still running (APRV-211). The flush below
|
|
481
|
+
// declines to start a second one, and this says so rather than letting a
|
|
482
|
+
// dangling `execution.started` be discovered later by a reader of the log:
|
|
483
|
+
// the child settles and the execution is closed if this process outlives
|
|
484
|
+
// it, and does not if it does not.
|
|
485
|
+
if (this.advanceInFlight !== null) {
|
|
486
|
+
this.warn("advance-refused", "an advance was still running in a child when the daemon stopped; its outcome is recorded only if this process outlives it, and `approval status` shows the execution as open until then");
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
this.advanceIfDue(true);
|
|
490
|
+
}
|
|
491
|
+
catch (cause) {
|
|
492
|
+
// A flush that throws must not stop the daemon from stopping.
|
|
493
|
+
this.warn("advance-refused", `the shutdown flush threw: ${errorMessage(cause)}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
this.emit({
|
|
497
|
+
event: "stopped",
|
|
498
|
+
reason: outcome.kind === "stopped" ? outcome.reason : outcome.kind,
|
|
499
|
+
ticks: this.ticks,
|
|
500
|
+
drift: this.drifts,
|
|
501
|
+
expired: this.expiries,
|
|
502
|
+
renders: this.renders,
|
|
503
|
+
});
|
|
504
|
+
const settle = this.settle;
|
|
505
|
+
this.settle = null;
|
|
506
|
+
if (settle !== null)
|
|
507
|
+
settle(outcome);
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Attach watchers to the task folder and the log's directory.
|
|
511
|
+
*
|
|
512
|
+
* The log's *directory* rather than the log file: an append to a file is
|
|
513
|
+
* observable either way, but a log that does not exist yet cannot be watched at
|
|
514
|
+
* all, and a rename (which `writeQueue` and any future rotation perform) leaves
|
|
515
|
+
* a file watcher pointed at an inode nobody writes to again.
|
|
516
|
+
*
|
|
517
|
+
* Failure is a warning, never fatal — see the module header on why the periodic
|
|
518
|
+
* tick makes watching optional.
|
|
519
|
+
*
|
|
520
|
+
* ## Ignoring the daemon's own hand (APRV-211)
|
|
521
|
+
*
|
|
522
|
+
* Two of the files in these directories are written by this loop itself: the
|
|
523
|
+
* verified-head snapshot beside the log (`verified-head.json` and its temp
|
|
524
|
+
* file, published on every clean read) and the task files {@link writeBack}
|
|
525
|
+
* repairs. A watcher that fires on those schedules a tick whose only cause was
|
|
526
|
+
* the previous tick, and the daemon wakes itself forever: measured at 18 ticks
|
|
527
|
+
* in 45 seconds against a ten-minute interval, with no other writer.
|
|
528
|
+
*
|
|
529
|
+
* So the log-directory watcher schedules only for the log file itself (or for
|
|
530
|
+
* an event that names no file, which is the platform saying "something here
|
|
531
|
+
* changed" and must still be believed), and the tasks watcher ignores the
|
|
532
|
+
* basenames this daemon just placed.
|
|
533
|
+
*
|
|
534
|
+
* ## Bookkeeping, nobody's hand (APRV-230)
|
|
535
|
+
*
|
|
536
|
+
* The self-wake was not the whole of it. Both directories also carry files
|
|
537
|
+
* that no reader ever reads: the append lockfile every writer in this runtime
|
|
538
|
+
* creates and removes around each append, and the swap, autosave and backup
|
|
539
|
+
* files an editor scatters beside a task file. Those are events about how a
|
|
540
|
+
* change was made, and a tick scheduled for one re-derives an answer nothing
|
|
541
|
+
* has moved. {@link bookkeepingKind} names them and they are dropped here, in
|
|
542
|
+
* both directories.
|
|
543
|
+
*
|
|
544
|
+
* What is left unattributed on purpose is a platform event that names no file.
|
|
545
|
+
* That is the platform saying "something in this directory changed" and
|
|
546
|
+
* declining to say what, and the log itself is one of the things it might
|
|
547
|
+
* have been; believing it costs a tick and doubting it could cost an append's
|
|
548
|
+
* latency. {@link DaemonOptions.traceWatch} makes every one of these decisions
|
|
549
|
+
* visible, which is how the remaining wake sources get counted rather than
|
|
550
|
+
* guessed at.
|
|
551
|
+
*
|
|
552
|
+
* This is safe for exactly the reason stated in the module header: correctness
|
|
553
|
+
* never depended on the watcher. Every tick re-scans the folder and re-derives
|
|
554
|
+
* everything from the verified log, and the periodic tick runs regardless
|
|
555
|
+
* (SPEC.md §10.2). The worst an over-eager filter can cost is latency on a
|
|
556
|
+
* change that arrives inside the same window as one of the daemon's own
|
|
557
|
+
* writes, and the next periodic tick collects it.
|
|
558
|
+
*/
|
|
559
|
+
attachWatchers() {
|
|
560
|
+
if (this.watchAttempted)
|
|
561
|
+
return;
|
|
562
|
+
this.watchAttempted = true;
|
|
563
|
+
const logName = basename(this.options.logPath);
|
|
564
|
+
const ownTempFile = new RegExp(`^\\..*\\.tmp-${String(process.pid)}-\\d+$`, "u");
|
|
565
|
+
/** Decide one watcher event, report it under the trace, and act on it. */
|
|
566
|
+
const handle = (source, type, raw) => {
|
|
567
|
+
const file = raw === null || raw === undefined || raw === "" ? null : raw;
|
|
568
|
+
const reason = this.classifyWatchEvent(source, file, logName, ownTempFile);
|
|
569
|
+
if (this.options.traceWatch === true) {
|
|
570
|
+
this.emit({
|
|
571
|
+
event: "watch",
|
|
572
|
+
watcher: source,
|
|
573
|
+
type,
|
|
574
|
+
file,
|
|
575
|
+
action: reason === null ? "scheduled" : "ignored",
|
|
576
|
+
reason,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
if (reason === null)
|
|
580
|
+
this.schedule({ source, file });
|
|
581
|
+
};
|
|
582
|
+
const triggers = {
|
|
583
|
+
tasks: (type, name) => {
|
|
584
|
+
handle("tasks", type, name);
|
|
585
|
+
},
|
|
586
|
+
log: (type, name) => {
|
|
587
|
+
handle("log", type, name);
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
for (const [label, dir] of [
|
|
591
|
+
["tasks", this.options.tasksDir],
|
|
592
|
+
["log", dirname(this.options.logPath)],
|
|
593
|
+
]) {
|
|
594
|
+
try {
|
|
595
|
+
const watcher = watch(dir, { persistent: true }, triggers[label]);
|
|
596
|
+
watcher.on("error", () => {
|
|
597
|
+
// A watcher that errors (its directory was removed, the platform ran
|
|
598
|
+
// out of handles) is simply dropped. The periodic tick continues to
|
|
599
|
+
// cover everything it was watching.
|
|
600
|
+
});
|
|
601
|
+
this.watchers.push(watcher);
|
|
602
|
+
this.watching = true;
|
|
603
|
+
}
|
|
604
|
+
catch (cause) {
|
|
605
|
+
this.warn("watch-unavailable", `the ${label} directory ${this.display(dir)} could not be watched (${errorMessage(cause)}); the daemon still re-scans it every ${String(this.options.intervalMs)}ms, so this costs latency and never correctness`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Whether one watcher event schedules a tick, and if not, why not (APRV-230).
|
|
611
|
+
*
|
|
612
|
+
* Pure, apart from reading the two sets of basenames this loop's own
|
|
613
|
+
* write-back placed. `null` means "schedule"; every other answer is a reason
|
|
614
|
+
* an operator can count.
|
|
615
|
+
*/
|
|
616
|
+
classifyWatchEvent(source, file, logName, ownTempFile) {
|
|
617
|
+
// A platform that will not say which file moved is saying "something in
|
|
618
|
+
// here changed", and one of the things it might be is the log: believe it.
|
|
619
|
+
if (file === null)
|
|
620
|
+
return null;
|
|
621
|
+
if (source === "log") {
|
|
622
|
+
if (file === logName)
|
|
623
|
+
return null;
|
|
624
|
+
// The lockfile first, so the trace names what it actually was rather than
|
|
625
|
+
// lumping it in with the verified-head snapshot beside it.
|
|
626
|
+
if (bookkeepingKind(file) !== null)
|
|
627
|
+
return "bookkeeping";
|
|
628
|
+
return "not-the-log";
|
|
629
|
+
}
|
|
630
|
+
if (this.selfWrites.has(file) || this.previousSelfWrites.has(file))
|
|
631
|
+
return "self-write";
|
|
632
|
+
// `core/task-file.ts` places a file through `.<name>.tmp-<pid>-<n>` in the
|
|
633
|
+
// same directory, and that temp file's create and rename are two more
|
|
634
|
+
// events about a write this process made.
|
|
635
|
+
if (ownTempFile.test(file))
|
|
636
|
+
return "own-temp";
|
|
637
|
+
if (bookkeepingKind(file) !== null)
|
|
638
|
+
return "bookkeeping";
|
|
639
|
+
return null;
|
|
640
|
+
}
|
|
641
|
+
/** Coalesce a burst of watcher events into one tick. */
|
|
642
|
+
schedule(wake) {
|
|
643
|
+
if (this.finished)
|
|
644
|
+
return;
|
|
645
|
+
// The event that OPENED the window is the one that woke the daemon
|
|
646
|
+
// (APRV-230); the ones behind it in the same burst only move the deadline,
|
|
647
|
+
// and the tick they share is attributed to the first of them.
|
|
648
|
+
this.pendingWake ??= wake;
|
|
649
|
+
if (this.debounce !== null)
|
|
650
|
+
clearTimeout(this.debounce);
|
|
651
|
+
this.debounce = setTimeout(() => {
|
|
652
|
+
this.debounce = null;
|
|
653
|
+
const outcome = this.tick();
|
|
654
|
+
if (outcome !== null)
|
|
655
|
+
this.finish(outcome);
|
|
656
|
+
}, this.options.debounceMs);
|
|
657
|
+
}
|
|
658
|
+
// -------------------------------------------------------------------------
|
|
659
|
+
// One tick
|
|
660
|
+
// -------------------------------------------------------------------------
|
|
661
|
+
/**
|
|
662
|
+
* One full pass: drift scan, TTL sweep, write-back, escalation surfacing,
|
|
663
|
+
* queue render.
|
|
664
|
+
*
|
|
665
|
+
* Returns `null` to continue, or the outcome that must stop the loop. Every
|
|
666
|
+
* step re-reads the verified log rather than sharing one snapshot across the
|
|
667
|
+
* pass, because each append invalidates the head the next one would build on
|
|
668
|
+
* and because an external writer may have moved the log in between. Reading is
|
|
669
|
+
* O(n) per call at v0.1 (`core/state.ts` documents the deferral); the head
|
|
670
|
+
* cache is APRV-43, and correctness comes first.
|
|
671
|
+
*/
|
|
672
|
+
tick() {
|
|
673
|
+
if (this.ticking)
|
|
674
|
+
return null;
|
|
675
|
+
this.ticking = true;
|
|
676
|
+
const startedAt = performance.now();
|
|
677
|
+
const phases = {
|
|
678
|
+
drift: 0,
|
|
679
|
+
ttl: 0,
|
|
680
|
+
audit: 0,
|
|
681
|
+
dark: 0,
|
|
682
|
+
prune: 0,
|
|
683
|
+
write_back: 0,
|
|
684
|
+
advance: 0,
|
|
685
|
+
escalations: 0,
|
|
686
|
+
render: 0,
|
|
687
|
+
};
|
|
688
|
+
/** Time `step`, add it to `phase`, and hand back what it returned. */
|
|
689
|
+
const timed = (phase, step) => {
|
|
690
|
+
const from = performance.now();
|
|
691
|
+
try {
|
|
692
|
+
return step();
|
|
693
|
+
}
|
|
694
|
+
finally {
|
|
695
|
+
phases[phase] += performance.now() - from;
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
// What woke this tick (APRV-230), taken exactly once: the next watcher
|
|
699
|
+
// event opens a fresh window, and a tick that ran on the interval reports
|
|
700
|
+
// the wake it happened to cover rather than leaving it for a second tick.
|
|
701
|
+
const woke = this.pendingWake;
|
|
702
|
+
this.pendingWake = null;
|
|
703
|
+
try {
|
|
704
|
+
this.ticks += 1;
|
|
705
|
+
this.reads = 0;
|
|
706
|
+
this.fullReproofThisTick = false;
|
|
707
|
+
this.anchorThisTick = null;
|
|
708
|
+
this.checkpointsThisTick = null;
|
|
709
|
+
// One generation of the daemon's own task-file writes is kept, because a
|
|
710
|
+
// watch event arrives after the write that caused it and often after the
|
|
711
|
+
// tick that made it has ended.
|
|
712
|
+
this.previousSelfWrites = this.selfWrites;
|
|
713
|
+
this.selfWrites = new Set();
|
|
714
|
+
// Late-attaching watchers: a log directory (or a task folder) created after
|
|
715
|
+
// startup becomes watchable, and the operator gets the latency back.
|
|
716
|
+
if (!this.options.once && !this.watching) {
|
|
717
|
+
this.watchAttempted = false;
|
|
718
|
+
this.attachWatchers();
|
|
719
|
+
}
|
|
720
|
+
const opening = this.read();
|
|
721
|
+
if (!opening.ok)
|
|
722
|
+
return this.fatal(opening);
|
|
723
|
+
// The anchor check (APRV-219), on the full re-proof cadence and on the
|
|
724
|
+
// first tick, which is always a cold walk. Placed immediately after the
|
|
725
|
+
// opening read and before any append this tick could make: a log that
|
|
726
|
+
// contradicts its own committed copy is not fit to be appended to, and
|
|
727
|
+
// the sweeps below all append.
|
|
728
|
+
if (this.fullReproofThisTick) {
|
|
729
|
+
const anchor = this.compareToAnchor(opening.records);
|
|
730
|
+
if (anchor !== null) {
|
|
731
|
+
if (anchor.status === "diverged") {
|
|
732
|
+
return { kind: "anchor-diverged", message: anchor.message };
|
|
733
|
+
}
|
|
734
|
+
if (anchor.status === "behind") {
|
|
735
|
+
this.warn("anchor-behind", `${anchor.detail}. Nothing was appended onto the shorter chain by this check; run \`approval log sync\` before this daemon writes anything`);
|
|
736
|
+
}
|
|
737
|
+
this.anchorThisTick =
|
|
738
|
+
anchor.status === "skip"
|
|
739
|
+
? { status: "skip", rev: null, seq: null }
|
|
740
|
+
: { status: anchor.status, rev: anchor.anchor.rev, seq: anchor.anchor.head.seq };
|
|
741
|
+
}
|
|
742
|
+
// The second witness (APRV-220), on the same cadence and immediately
|
|
743
|
+
// after the first. A log whose own signed checkpoints contradict it is
|
|
744
|
+
// no more fit to append to than one whose committed copy does, and the
|
|
745
|
+
// sweeps below all append. Independent of the anchor in both
|
|
746
|
+
// directions: a skip on one never excuses the other.
|
|
747
|
+
const checkpoints = this.checkCheckpoints(opening.records);
|
|
748
|
+
if (checkpoints !== null) {
|
|
749
|
+
if (checkpoints.status === "refused") {
|
|
750
|
+
return { kind: "checkpoint-invalid", message: checkpoints.message };
|
|
751
|
+
}
|
|
752
|
+
if (checkpoints.status === "skip") {
|
|
753
|
+
this.checkpointsThisTick = { status: "skip", verified: 0, keys: 0, due: false };
|
|
754
|
+
}
|
|
755
|
+
else {
|
|
756
|
+
// APRV-257. The warning and the `due` flag are one fact read once:
|
|
757
|
+
// `checkpoints.warning` is non-null exactly when
|
|
758
|
+
// `core/checkpoint.ts`'s due-ness rule says a checkpoint is owed,
|
|
759
|
+
// which is the same rule the dispatch cycle enqueues its prompt
|
|
760
|
+
// from. A `warn`, deliberately, and there is no branch below it
|
|
761
|
+
// that could become a `fatal`: a human who has been away is not
|
|
762
|
+
// tampering.
|
|
763
|
+
if (checkpoints.warning !== null)
|
|
764
|
+
this.warn("checkpoint-due", checkpoints.warning);
|
|
765
|
+
this.checkpointsThisTick = {
|
|
766
|
+
status: "pass",
|
|
767
|
+
verified: checkpoints.checkpoints.length,
|
|
768
|
+
keys: checkpoints.keys,
|
|
769
|
+
due: checkpoints.warning !== null,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
const drift = timed("drift", () => this.scanForDrift());
|
|
775
|
+
if (drift.stop !== null)
|
|
776
|
+
return drift.stop;
|
|
777
|
+
const expired = timed("ttl", () => this.sweepTtl());
|
|
778
|
+
if (expired.stop !== null)
|
|
779
|
+
return expired.stop;
|
|
780
|
+
// Audit sampling (APRV-40, SPEC.md §5.2/§10.2). Placed before the closing
|
|
781
|
+
// read and the render so a sample appended here is counted by this tick's
|
|
782
|
+
// head and shows up in this tick's `audit_backlog`. It decides nothing:
|
|
783
|
+
// `daemon/audit.ts` re-derives eligibility from the verified log and every
|
|
784
|
+
// append is a compare-and-append.
|
|
785
|
+
timed("audit", () => sweepAuditSampling({
|
|
786
|
+
logPath: this.options.logPath,
|
|
787
|
+
policy: this.options.policy,
|
|
788
|
+
cwd: this.options.cwd,
|
|
789
|
+
...(this.options.schemaDir === undefined ? {} : { schemaDir: this.options.schemaDir }),
|
|
790
|
+
...(this.options.clock === undefined ? {} : { clock: this.options.clock }),
|
|
791
|
+
warn: (message) => this.warn("append-refused", message),
|
|
792
|
+
// One line per sample appended (APRV-57). The sweep names no event; it
|
|
793
|
+
// hands back what it wrote and the loop says it in the loop's own words.
|
|
794
|
+
sampled: (sample) => this.emit({
|
|
795
|
+
event: "sampled",
|
|
796
|
+
action_key: sample.candidate.actionKey,
|
|
797
|
+
task: sample.candidate.task,
|
|
798
|
+
seq: sample.record.seq,
|
|
799
|
+
subject_seq: sample.candidate.seq,
|
|
800
|
+
}),
|
|
801
|
+
}));
|
|
802
|
+
// The dark-session sweep (APRV-192), on its own cadence. Placed with the
|
|
803
|
+
// audit sweep because it is the same kind of thing — a detective control
|
|
804
|
+
// that re-derives its whole question from the verified log and the world,
|
|
805
|
+
// appends what is new, and changes no verdict. It runs BEFORE the prune so
|
|
806
|
+
// that an observation it appends is counted by this tick's closing head.
|
|
807
|
+
timed("dark", () => {
|
|
808
|
+
this.sweepDark();
|
|
809
|
+
});
|
|
810
|
+
// Payload retention (APRV-41), after the TTL sweep so a request expired on
|
|
811
|
+
// this tick is judged against the record the sweep just wrote. The pruner
|
|
812
|
+
// owns the rule, the append and the unlink; the daemon owns only the
|
|
813
|
+
// scheduling, which is the one thing `daemon/prune.ts` deliberately lacks.
|
|
814
|
+
timed("prune", () => {
|
|
815
|
+
this.prune();
|
|
816
|
+
});
|
|
817
|
+
// Projection write-back (SPEC.md §6.3, APRV-62). Last, because it copies
|
|
818
|
+
// the log into the files and every append this tick can make has now been
|
|
819
|
+
// made: a request expired by the sweep above is reflected on disk by this
|
|
820
|
+
// same tick rather than surfacing as drift on the next one. It appends
|
|
821
|
+
// nothing itself, so its position cannot affect any record.
|
|
822
|
+
const wrote = timed("write_back", () => this.writeBack());
|
|
823
|
+
if (wrote !== null)
|
|
824
|
+
return wrote;
|
|
825
|
+
// The cadence advance (APRV-204), after every append this tick can make
|
|
826
|
+
// and before the closing read, so the head this tick reports is the head
|
|
827
|
+
// the advance published against. It appends through the gate rather than
|
|
828
|
+
// through this loop, and everything it appends is picked up by the read
|
|
829
|
+
// below like any other writer's.
|
|
830
|
+
timed("advance", () => {
|
|
831
|
+
this.advanceIfDue(false);
|
|
832
|
+
});
|
|
833
|
+
const closing = this.read();
|
|
834
|
+
if (!closing.ok)
|
|
835
|
+
return this.fatal(closing);
|
|
836
|
+
const escalated = timed("escalations", () => this.surfaceEscalations(closing.records));
|
|
837
|
+
timed("render", () => {
|
|
838
|
+
this.render();
|
|
839
|
+
});
|
|
840
|
+
this.options.gitEvidence?.commit(closing.head);
|
|
841
|
+
this.emit({
|
|
842
|
+
event: "tick",
|
|
843
|
+
n: this.ticks,
|
|
844
|
+
head: closing.head === null ? null : closing.head.seq,
|
|
845
|
+
drift: drift.appended,
|
|
846
|
+
expired: expired.appended,
|
|
847
|
+
escalated,
|
|
848
|
+
ms: Math.round((performance.now() - startedAt) * 10) / 10,
|
|
849
|
+
reads: this.reads,
|
|
850
|
+
reproof: this.fullReproofThisTick ? "full" : "incremental",
|
|
851
|
+
...(this.anchorThisTick === null ? {} : { anchor: this.anchorThisTick }),
|
|
852
|
+
...(this.checkpointsThisTick === null ? {} : { checkpoints: this.checkpointsThisTick }),
|
|
853
|
+
woke_by: woke === null ? "interval" : woke.source,
|
|
854
|
+
...(woke === null || woke.file === null ? {} : { woke_file: woke.file }),
|
|
855
|
+
phases: {
|
|
856
|
+
drift: Math.round(phases.drift * 10) / 10,
|
|
857
|
+
ttl: Math.round(phases.ttl * 10) / 10,
|
|
858
|
+
audit: Math.round(phases.audit * 10) / 10,
|
|
859
|
+
dark: Math.round(phases.dark * 10) / 10,
|
|
860
|
+
prune: Math.round(phases.prune * 10) / 10,
|
|
861
|
+
write_back: Math.round(phases.write_back * 10) / 10,
|
|
862
|
+
advance: Math.round(phases.advance * 10) / 10,
|
|
863
|
+
escalations: Math.round(phases.escalations * 10) / 10,
|
|
864
|
+
render: Math.round(phases.render * 10) / 10,
|
|
865
|
+
},
|
|
866
|
+
});
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
finally {
|
|
870
|
+
this.ticking = false;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
// -------------------------------------------------------------------------
|
|
874
|
+
// The dark-session sweep (APRV-192)
|
|
875
|
+
// -------------------------------------------------------------------------
|
|
876
|
+
/**
|
|
877
|
+
* Ask git what happened, and the log whether it was told.
|
|
878
|
+
*
|
|
879
|
+
* On its own interval rather than every tick: the tick is 30 seconds by
|
|
880
|
+
* default and a `git log` per worktree at that rate is work spent to re-read
|
|
881
|
+
* an unchanged answer. The interval is a floor and never a ceiling — a sweep
|
|
882
|
+
* missed because the daemon was down is simply made by the next one, since
|
|
883
|
+
* the sweep holds no cursor and re-derives its whole question from the window
|
|
884
|
+
* it is given.
|
|
885
|
+
*
|
|
886
|
+
* The daemon owns the SCHEDULING and nothing else, which is the division the
|
|
887
|
+
* drift scan, the TTL sweep and the audit sweep already keep.
|
|
888
|
+
*/
|
|
889
|
+
sweepDark() {
|
|
890
|
+
const watch = this.options.darkSessions;
|
|
891
|
+
if (watch === undefined)
|
|
892
|
+
return;
|
|
893
|
+
const now = Date.parse(readClock(this.clockOptions()));
|
|
894
|
+
if (this.lastDarkSweepAt !== null &&
|
|
895
|
+
!Number.isNaN(now) &&
|
|
896
|
+
now - this.lastDarkSweepAt < watch.intervalMs) {
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
this.lastDarkSweepAt = Number.isNaN(now) ? 0 : now;
|
|
900
|
+
const read = this.read();
|
|
901
|
+
const result = sweepDarkSessions({
|
|
902
|
+
logPath: this.options.logPath,
|
|
903
|
+
root: this.options.cwd,
|
|
904
|
+
policy: this.options.policy,
|
|
905
|
+
...(this.options.schemaDir === undefined ? {} : { schemaDir: this.options.schemaDir }),
|
|
906
|
+
...(this.options.clock === undefined ? {} : { clock: this.options.clock }),
|
|
907
|
+
...(this.options.observeGit === undefined ? {} : { observe: this.options.observeGit }),
|
|
908
|
+
windowMs: watch.windowMs,
|
|
909
|
+
records: read.ok ? read.records : null,
|
|
910
|
+
...(read.ok ? {} : { logDetail: read.message }),
|
|
911
|
+
});
|
|
912
|
+
for (const entry of result.appended) {
|
|
913
|
+
this.emit({
|
|
914
|
+
event: "dark_session",
|
|
915
|
+
verdict: "dark",
|
|
916
|
+
subject: entry.finding.subject,
|
|
917
|
+
branch: entry.finding.branch,
|
|
918
|
+
code: entry.finding.code ?? "no-records",
|
|
919
|
+
commits: entry.finding.commits,
|
|
920
|
+
seq: entry.seq,
|
|
921
|
+
already_recorded: false,
|
|
922
|
+
message: entry.finding.detail,
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
for (const finding of result.repeated) {
|
|
926
|
+
this.emit({
|
|
927
|
+
event: "dark_session",
|
|
928
|
+
verdict: "dark",
|
|
929
|
+
subject: finding.subject,
|
|
930
|
+
branch: finding.branch,
|
|
931
|
+
code: finding.code ?? "no-records",
|
|
932
|
+
commits: finding.commits,
|
|
933
|
+
seq: null,
|
|
934
|
+
already_recorded: true,
|
|
935
|
+
message: finding.detail,
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
for (const finding of result.undetermined) {
|
|
939
|
+
this.emit({
|
|
940
|
+
event: "dark_session",
|
|
941
|
+
verdict: "undetermined",
|
|
942
|
+
subject: finding.subject,
|
|
943
|
+
branch: finding.branch,
|
|
944
|
+
code: finding.code ?? "git-unavailable",
|
|
945
|
+
commits: finding.commits,
|
|
946
|
+
seq: null,
|
|
947
|
+
already_recorded: false,
|
|
948
|
+
message: finding.detail,
|
|
949
|
+
});
|
|
950
|
+
this.warn("dark-session-undetermined", `the dark-session sweep could not establish ${finding.subject} (${finding.code ?? "?"}): ${finding.detail}`);
|
|
951
|
+
}
|
|
952
|
+
for (const message of result.refusals)
|
|
953
|
+
this.warn("append-refused", message);
|
|
954
|
+
}
|
|
955
|
+
// -------------------------------------------------------------------------
|
|
956
|
+
// The cadence advance (APRV-204)
|
|
957
|
+
// -------------------------------------------------------------------------
|
|
958
|
+
/**
|
|
959
|
+
* Advance the log if the cadence says it is due, or if this is the flush.
|
|
960
|
+
*
|
|
961
|
+
* The trigger, in one place: enough SUBSTANTIVE records have accrued
|
|
962
|
+
* (`afterRecords`), or the interval has elapsed since the last attempt and at
|
|
963
|
+
* least one substantive record is owed. "Substantive" excludes the advance
|
|
964
|
+
* cycle's own bookkeeping — see `daemon/advance.ts` on why counting it would
|
|
965
|
+
* make an idle repository advance forever.
|
|
966
|
+
*
|
|
967
|
+
* The last-attempt clock is set for every attempt, successful or not, which
|
|
968
|
+
* is what keeps a refusal off the hot path: a gate that says no costs one
|
|
969
|
+
* attempt per interval and no more.
|
|
970
|
+
*
|
|
971
|
+
* The flush ignores the interval and the count, and only the interval and the
|
|
972
|
+
* count: it still asks the gate, and it still does nothing when nothing is
|
|
973
|
+
* owed.
|
|
974
|
+
*/
|
|
975
|
+
/**
|
|
976
|
+
* The advance cycles the started line names, and nothing else (APRV-264).
|
|
977
|
+
*
|
|
978
|
+
* Reads and proves; appends nothing. The first tick runs a moment later and
|
|
979
|
+
* sweeps for real, so anything provable here is closed before an operator has
|
|
980
|
+
* finished reading the line — which is why the line carries only what is NOT
|
|
981
|
+
* provable, the part that needs a person. Every key it names is marked
|
|
982
|
+
* reported, so the sweep that follows does not warn about it again.
|
|
983
|
+
*/
|
|
984
|
+
listDanglingAdvancesAtStartup() {
|
|
985
|
+
const cadence = this.options.advance;
|
|
986
|
+
if (cadence === undefined)
|
|
987
|
+
return [];
|
|
988
|
+
const read = this.read();
|
|
989
|
+
if (!read.ok)
|
|
990
|
+
return [];
|
|
991
|
+
let outstanding;
|
|
992
|
+
try {
|
|
993
|
+
outstanding = proveDanglingAdvancesFor(this.advanceInputFor(cadence), read.records)
|
|
994
|
+
.filter((entry) => entry.provenBy === null)
|
|
995
|
+
.map((entry) => entry.actionKey);
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
// A git read that throws (no repository, an unreadable object store) is
|
|
999
|
+
// not a reason for the daemon to fail to start. The tick reports it.
|
|
1000
|
+
return [];
|
|
1001
|
+
}
|
|
1002
|
+
for (const key of outstanding)
|
|
1003
|
+
this.reportedDangling.add(key);
|
|
1004
|
+
return outstanding;
|
|
1005
|
+
}
|
|
1006
|
+
/** Everything `daemon/advance.ts` needs, built from this daemon's options. */
|
|
1007
|
+
advanceInputFor(cadence) {
|
|
1008
|
+
return {
|
|
1009
|
+
logPath: this.options.logPath,
|
|
1010
|
+
cwd: this.options.cwd,
|
|
1011
|
+
policy: this.options.policy,
|
|
1012
|
+
cadence,
|
|
1013
|
+
...(this.options.schemaDir === undefined ? {} : { schemaDir: this.options.schemaDir }),
|
|
1014
|
+
...(this.options.clock === undefined ? {} : { clock: this.options.clock }),
|
|
1015
|
+
...(this.options.today === undefined ? {} : { today: this.options.today }),
|
|
1016
|
+
...(this.options.advanceRunner === undefined ? {} : { runner: this.options.advanceRunner }),
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
advanceIfDue(flush) {
|
|
1020
|
+
const cadence = this.options.advance;
|
|
1021
|
+
if (cadence === undefined)
|
|
1022
|
+
return;
|
|
1023
|
+
// An advance is already running in a child (APRV-211). Its records are not
|
|
1024
|
+
// on a branch yet and its execution is not closed yet, so a second attempt
|
|
1025
|
+
// now would ask about work that is already authorised and already moving.
|
|
1026
|
+
if (this.advanceInFlight !== null)
|
|
1027
|
+
return;
|
|
1028
|
+
const read = this.read();
|
|
1029
|
+
if (!read.ok)
|
|
1030
|
+
return;
|
|
1031
|
+
const root = this.options.cwd;
|
|
1032
|
+
const today = this.options.today ?? readClock(this.clockOptions());
|
|
1033
|
+
const input = this.advanceInputFor(cadence);
|
|
1034
|
+
// APRV-233, first: an outcome this process observed and could not record.
|
|
1035
|
+
// It is settled BEFORE any trigger is evaluated, on the head as it stands
|
|
1036
|
+
// now, and this tick does nothing else either way — a tick that both closed
|
|
1037
|
+
// an old cycle and opened a new one would be reasoning about a log it read
|
|
1038
|
+
// before it wrote to it.
|
|
1039
|
+
if (this.pendingAdvanceFinish !== null) {
|
|
1040
|
+
const settled = settleAdvanceFinish(input, this.pendingAdvanceFinish);
|
|
1041
|
+
if (settled.ok) {
|
|
1042
|
+
this.pendingAdvanceFinish = null;
|
|
1043
|
+
this.emit({
|
|
1044
|
+
event: "advance",
|
|
1045
|
+
outcome: "nothing-owed",
|
|
1046
|
+
records_pending: 0,
|
|
1047
|
+
records_branch: null,
|
|
1048
|
+
range: null,
|
|
1049
|
+
commit: null,
|
|
1050
|
+
pr_url: null,
|
|
1051
|
+
pr_created: false,
|
|
1052
|
+
auto_merge: null,
|
|
1053
|
+
auto_merge_note: null,
|
|
1054
|
+
rebuilt: false,
|
|
1055
|
+
rebuilt_on: null,
|
|
1056
|
+
code: "advance-settled",
|
|
1057
|
+
message: settled.message,
|
|
1058
|
+
flush,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
else {
|
|
1062
|
+
this.warn("advance-refused", settled.message);
|
|
1063
|
+
}
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
// APRV-233, and the same rule for cycles this process does not remember:
|
|
1067
|
+
// an advance whose outcome nobody recorded is reconciled from the git
|
|
1068
|
+
// evidence before anything else is attempted, and refused (never re-run)
|
|
1069
|
+
// when the evidence is not there. On 2026-09-02 the absence of this made
|
|
1070
|
+
// the next tick's authorization reach `startExecution` on the open key and
|
|
1071
|
+
// come back `already-executed`, which reported a failure and fixed nothing.
|
|
1072
|
+
//
|
|
1073
|
+
// APRV-264 widened it from one cycle to all of them. On 2026-09-05 five
|
|
1074
|
+
// stood at once and this closed one per tick behind a refusal that stopped
|
|
1075
|
+
// the cadence, so the operator repaired the pile by hand instead.
|
|
1076
|
+
const sweep = sweepDanglingAdvances(input, read.records);
|
|
1077
|
+
if (!sweepIsClear(sweep)) {
|
|
1078
|
+
for (const closed of sweep.settled) {
|
|
1079
|
+
this.emit({
|
|
1080
|
+
event: "advance",
|
|
1081
|
+
outcome: "nothing-owed",
|
|
1082
|
+
records_pending: 0,
|
|
1083
|
+
records_branch: null,
|
|
1084
|
+
range: null,
|
|
1085
|
+
commit: null,
|
|
1086
|
+
pr_url: null,
|
|
1087
|
+
pr_created: false,
|
|
1088
|
+
auto_merge: null,
|
|
1089
|
+
auto_merge_note: null,
|
|
1090
|
+
rebuilt: false,
|
|
1091
|
+
rebuilt_on: null,
|
|
1092
|
+
code: "advance-reconciled",
|
|
1093
|
+
message: closed.message,
|
|
1094
|
+
flush,
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
if (sweep.outstanding.length > 0) {
|
|
1098
|
+
// Once per key, not once per tick, and one line for all of them: the
|
|
1099
|
+
// repair is the same command whichever key an operator reads first, so
|
|
1100
|
+
// they are named together under it. The set is what makes it once —
|
|
1101
|
+
// a key already named on the started line or by an earlier tick is not
|
|
1102
|
+
// named again until it goes away and comes back.
|
|
1103
|
+
const unreported = sweep.outstanding.filter((entry) => !this.reportedDangling.has(entry.actionKey));
|
|
1104
|
+
if (unreported.length > 0) {
|
|
1105
|
+
for (const entry of sweep.outstanding)
|
|
1106
|
+
this.reportedDangling.add(entry.actionKey);
|
|
1107
|
+
this.warn("advance-refused", `${unreconciledRefusal(sweep.outstanding.map((entry) => entry.actionKey))} ${sweep.outstanding
|
|
1108
|
+
.map((entry) => entry.message)
|
|
1109
|
+
.join(" ")}`);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
this.reportedDangling.clear();
|
|
1115
|
+
const state = publishedState(root, this.options.logPath, read.records, cadence, today);
|
|
1116
|
+
if (state.substantive === 0)
|
|
1117
|
+
return;
|
|
1118
|
+
// The flush does not re-ask a question this process just asked. A tick
|
|
1119
|
+
// whose advance was gated or refused leaves a request in the queue; a flush
|
|
1120
|
+
// a moment later, against the same owed records, would put a SECOND
|
|
1121
|
+
// identical question in front of the same human. Measured in SUBSTANTIVE
|
|
1122
|
+
// records rather than in head seq, because the gated attempt's own request
|
|
1123
|
+
// moved the head and answered nothing. The retry is the next tick's
|
|
1124
|
+
// business, and the next daemon's.
|
|
1125
|
+
if (flush &&
|
|
1126
|
+
this.lastAdvance !== null &&
|
|
1127
|
+
this.lastAdvance.outcome !== "advanced" &&
|
|
1128
|
+
this.lastAdvanceOwed !== null &&
|
|
1129
|
+
state.substantive <= this.lastAdvanceOwed) {
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
const now = Date.parse(readClock(this.clockOptions()));
|
|
1133
|
+
const elapsed = this.lastAdvanceAt === null || Number.isNaN(now) || now - this.lastAdvanceAt >= cadence.intervalMs;
|
|
1134
|
+
// APRV-233, second: an advance that ALREADY HAPPENED does not get made
|
|
1135
|
+
// again inside the interval, and the record-count trigger does not run
|
|
1136
|
+
// around the interval for a span an earlier attempt already carried.
|
|
1137
|
+
//
|
|
1138
|
+
// The 2026-09-02 shape. The advance pushed `records-log-2026-09-02` and its
|
|
1139
|
+
// `execution.completed` lost the append race, so the only thing left saying
|
|
1140
|
+
// that a branch had just been pushed was an in-process clock — and the
|
|
1141
|
+
// count trigger, alone among the two, never consulted it. Ticks two, five
|
|
1142
|
+
// and eight each pushed the same branch again, ninety seconds apart, under
|
|
1143
|
+
// a fifteen-minute interval (the three-tick spacing is the one in-flight
|
|
1144
|
+
// slot: the two ticks in between found a child still running).
|
|
1145
|
+
//
|
|
1146
|
+
// An advance cycle still open in the log has already returned above, so
|
|
1147
|
+
// what is left is the trigger itself: inside the interval, the count
|
|
1148
|
+
// trigger fires only on records this process has not already attempted to
|
|
1149
|
+
// publish. `afterRecords` is the busy-hour trigger and it keeps working;
|
|
1150
|
+
// what it no longer does is count the same owed span over and over, which
|
|
1151
|
+
// is how four fresh records re-pushed the branch every ninety seconds while
|
|
1152
|
+
// the published head stood still.
|
|
1153
|
+
const fresh = this.lastAdvanceSpanEnd === null
|
|
1154
|
+
? state.substantive
|
|
1155
|
+
: read.records.filter((record) => record.seq > Math.max(state.publishedSeq, this.lastAdvanceSpanEnd ?? 0) &&
|
|
1156
|
+
!isAdvanceBookkeeping(record)).length;
|
|
1157
|
+
if (!flush && fresh < cadence.afterRecords && !elapsed)
|
|
1158
|
+
return;
|
|
1159
|
+
this.lastAdvanceAt = Number.isNaN(now) ? 0 : now;
|
|
1160
|
+
this.lastAdvanceOwed = state.substantive;
|
|
1161
|
+
this.lastAdvanceSpanEnd = state.substantiveSeq;
|
|
1162
|
+
// The gate, always here: the `supervised-live` draw reads a secret that
|
|
1163
|
+
// `core/child-env.ts` strips from every child, so authorization cannot
|
|
1164
|
+
// leave this process (APRV-205, APRV-211).
|
|
1165
|
+
const auth = authorizeAdvance(input, read.records);
|
|
1166
|
+
if (!auth.authorized) {
|
|
1167
|
+
this.reportAdvance(auth.attempt, flush);
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
// The git side effect, which may leave. A blocking `git push` on this stack
|
|
1171
|
+
// is a Telegram callback answered past its window (APRV-211), so the
|
|
1172
|
+
// periodic tick hands the verb to a child and returns to the loop. The
|
|
1173
|
+
// shutdown flush and `--once` keep the synchronous path: the first has no
|
|
1174
|
+
// loop left to return to, and the second is a process that exits at the end
|
|
1175
|
+
// of this tick, where an advance settling afterwards would be an advance
|
|
1176
|
+
// nobody recorded.
|
|
1177
|
+
if (flush || this.options.once === true) {
|
|
1178
|
+
this.reportAdvance(runAdvanceSync(input, auth), flush);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
this.advanceInFlight = runAdvanceAsync(input, auth)
|
|
1182
|
+
.then((attempt) => {
|
|
1183
|
+
this.reportAdvance(attempt, flush);
|
|
1184
|
+
})
|
|
1185
|
+
.catch((cause) => {
|
|
1186
|
+
this.warn("advance-refused", `the advance child could not be settled: ${errorMessage(cause)}`);
|
|
1187
|
+
})
|
|
1188
|
+
.finally(() => {
|
|
1189
|
+
this.advanceInFlight = null;
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
/** Record and report one finished attempt, from either runner. */
|
|
1193
|
+
reportAdvance(attempt, flush) {
|
|
1194
|
+
this.lastAdvance = attempt;
|
|
1195
|
+
// APRV-233. Held whatever the outcome was, and before the early return: an
|
|
1196
|
+
// outcome nobody recorded is the one thing this loop must not forget.
|
|
1197
|
+
this.pendingAdvanceFinish = attempt.pendingFinish;
|
|
1198
|
+
if (attempt.outcome === "nothing-owed" && attempt.pendingFinish === null)
|
|
1199
|
+
return;
|
|
1200
|
+
this.emit({
|
|
1201
|
+
event: "advance",
|
|
1202
|
+
outcome: attempt.outcome,
|
|
1203
|
+
records_pending: attempt.recordsPending,
|
|
1204
|
+
records_branch: attempt.recordsBranch,
|
|
1205
|
+
range: attempt.range,
|
|
1206
|
+
commit: attempt.commit,
|
|
1207
|
+
pr_url: attempt.prUrl,
|
|
1208
|
+
pr_created: attempt.prCreated,
|
|
1209
|
+
auto_merge: attempt.autoMerge,
|
|
1210
|
+
auto_merge_note: attempt.autoMergeNote,
|
|
1211
|
+
rebuilt: attempt.rebuilt,
|
|
1212
|
+
rebuilt_on: attempt.rebuiltOn,
|
|
1213
|
+
code: attempt.code,
|
|
1214
|
+
message: attempt.message,
|
|
1215
|
+
flush,
|
|
1216
|
+
});
|
|
1217
|
+
if (attempt.outcome !== "advanced") {
|
|
1218
|
+
this.warn("advance-refused", `the cadence advance did not publish (${attempt.outcome}${attempt.code === null ? "" : `, ${attempt.code}`}): ${attempt.message}`);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
/** The clock this loop reads, as the options every core writer takes it. */
|
|
1222
|
+
clockOptions() {
|
|
1223
|
+
return this.options.clock === undefined ? {} : { clock: this.options.clock };
|
|
1224
|
+
}
|
|
1225
|
+
/** The last attempt this process made, for a caller that wants to assert on it. */
|
|
1226
|
+
lastAdvanceAttempt() {
|
|
1227
|
+
return this.lastAdvance;
|
|
1228
|
+
}
|
|
1229
|
+
// -------------------------------------------------------------------------
|
|
1230
|
+
// Log anchoring (APRV-219)
|
|
1231
|
+
// -------------------------------------------------------------------------
|
|
1232
|
+
/** Is the anchor check on for this run? On unless the operator turned it off. */
|
|
1233
|
+
anchorEnabled() {
|
|
1234
|
+
return this.options.anchor?.enabled !== false;
|
|
1235
|
+
}
|
|
1236
|
+
/** The anchor options this loop passes down, assembled once and identically. */
|
|
1237
|
+
anchorWhere() {
|
|
1238
|
+
const anchor = this.options.anchor;
|
|
1239
|
+
return {
|
|
1240
|
+
...(anchor?.rev === undefined ? {} : { rev: anchor.rev }),
|
|
1241
|
+
...(anchor?.remote === undefined ? {} : { remote: anchor.remote }),
|
|
1242
|
+
...(anchor?.base === undefined ? {} : { base: anchor.base }),
|
|
1243
|
+
...(this.options.today === undefined ? {} : { today: this.options.today }),
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
/**
|
|
1247
|
+
* Which committed copy this run is anchored against, for the `started` line.
|
|
1248
|
+
*
|
|
1249
|
+
* Resolution only: it asks git which revs carry a copy of the log and which
|
|
1250
|
+
* of them reaches furthest, and compares nothing. The first tick, a line
|
|
1251
|
+
* below the `started` emit, makes the comparison — and always in full, since
|
|
1252
|
+
* a cold walk is a full re-proof by construction.
|
|
1253
|
+
*/
|
|
1254
|
+
resolveAnchorForReport() {
|
|
1255
|
+
if (!this.anchorEnabled()) {
|
|
1256
|
+
return { rev: null, seq: null, reason: "the anchor check is disabled for this run" };
|
|
1257
|
+
}
|
|
1258
|
+
const root = repoRoot(dirname(this.options.logPath));
|
|
1259
|
+
if (root === null) {
|
|
1260
|
+
return {
|
|
1261
|
+
rev: null,
|
|
1262
|
+
seq: null,
|
|
1263
|
+
reason: `${this.display(this.options.logPath)} is not inside a git repository`,
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
const resolved = resolveAnchor(root, this.options.logPath, {
|
|
1267
|
+
...this.anchorWhere(),
|
|
1268
|
+
...(this.options.schemaDir === undefined ? {} : { schemaDir: this.options.schemaDir }),
|
|
1269
|
+
});
|
|
1270
|
+
return resolved.ok
|
|
1271
|
+
? { rev: resolved.anchor.rev, seq: resolved.anchor.head.seq, reason: null }
|
|
1272
|
+
: { rev: null, seq: null, reason: resolved.reason };
|
|
1273
|
+
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Compare the verified working records against the committed copy.
|
|
1276
|
+
*
|
|
1277
|
+
* `null` when the check is off. Everything else — including "there is no
|
|
1278
|
+
* committed copy" — comes back as an {@link AnchorCheck} the caller reports,
|
|
1279
|
+
* because a check that could not look must never be read as a check that
|
|
1280
|
+
* looked and was satisfied.
|
|
1281
|
+
*/
|
|
1282
|
+
compareToAnchor(records) {
|
|
1283
|
+
if (!this.anchorEnabled())
|
|
1284
|
+
return null;
|
|
1285
|
+
return checkLogAnchor({
|
|
1286
|
+
logPath: this.options.logPath,
|
|
1287
|
+
records,
|
|
1288
|
+
...this.anchorWhere(),
|
|
1289
|
+
...(this.options.schemaDir === undefined ? {} : { schemaDir: this.options.schemaDir }),
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
// -------------------------------------------------------------------------
|
|
1293
|
+
// Human-signed checkpoints (APRV-220)
|
|
1294
|
+
// -------------------------------------------------------------------------
|
|
1295
|
+
/**
|
|
1296
|
+
* Demand every checkpoint inside the verified records.
|
|
1297
|
+
*
|
|
1298
|
+
* `null` when the check is off. Everything else — including "no key is
|
|
1299
|
+
* configured" — comes back as a {@link CheckpointCheck} the caller reports,
|
|
1300
|
+
* because a check that could not look must never be read as a check that
|
|
1301
|
+
* looked and was satisfied.
|
|
1302
|
+
*
|
|
1303
|
+
* The policy is read here, once per comparison rather than once per run: the
|
|
1304
|
+
* keys are the human's and the human may add one while this loop is running,
|
|
1305
|
+
* and a daemon holding a key list from startup would keep refusing a
|
|
1306
|
+
* checkpoint the operator had already authorized by editing the policy.
|
|
1307
|
+
*/
|
|
1308
|
+
checkCheckpoints(records) {
|
|
1309
|
+
if (this.options.checkpoints?.enabled === false)
|
|
1310
|
+
return null;
|
|
1311
|
+
const configured = checkpointPolicyOf(this.options.policy, this.options.schemaDir);
|
|
1312
|
+
return checkLogCheckpoints({
|
|
1313
|
+
records,
|
|
1314
|
+
publicKeys: configured.publicKeys,
|
|
1315
|
+
checkpointEveryMs: configured.checkpointEveryMs,
|
|
1316
|
+
keysUnavailable: configured.unloadable,
|
|
1317
|
+
...(this.options.clock === undefined
|
|
1318
|
+
? {}
|
|
1319
|
+
: { now: Date.parse(this.options.clock()) }),
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
fatal(read) {
|
|
1323
|
+
switch (read.code) {
|
|
1324
|
+
case "log-unreadable":
|
|
1325
|
+
return { kind: "log-unreadable", message: read.message };
|
|
1326
|
+
case "log-torn-tail":
|
|
1327
|
+
return { kind: "log-torn-tail", message: read.message };
|
|
1328
|
+
case "log-corrupt":
|
|
1329
|
+
return { kind: "log-corrupt", message: read.message };
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* The verified log, and — since APRV-188 — the publication of what was
|
|
1334
|
+
* verified.
|
|
1335
|
+
*
|
|
1336
|
+
* The daemon holds a warm {@link VerifiedReadCache} and re-verifies only the
|
|
1337
|
+
* appended tail on every tick. Every hook process, by contrast, starts with an
|
|
1338
|
+
* empty cache and walks the whole chain before it may decide anything. So on
|
|
1339
|
+
* each clean read this loop publishes a verified-head snapshot beside the log:
|
|
1340
|
+
* an endorsement of the exact bytes it just walked, which the next hook
|
|
1341
|
+
* process re-proves for itself (one SHA-256) instead of re-walking. See
|
|
1342
|
+
* `core/verified-snapshot.ts` for what that endorsement claims and what a
|
|
1343
|
+
* reader still checks.
|
|
1344
|
+
*
|
|
1345
|
+
* The publication rides on the read rather than following it, so the bytes
|
|
1346
|
+
* endorsed are the bytes verified: a publisher that re-read the file to hash
|
|
1347
|
+
* it could endorse a digest of bytes nobody walked.
|
|
1348
|
+
*/
|
|
1349
|
+
read() {
|
|
1350
|
+
this.reads += 1;
|
|
1351
|
+
// APRV-217. Measured around THIS read, so the tick line reports the path
|
|
1352
|
+
// the loop's own reads took: other readers in the same process (the queue
|
|
1353
|
+
// renderer) have their own answer and their own line to be judged on.
|
|
1354
|
+
const fullBefore = processReadCache.stats.fullReproofs;
|
|
1355
|
+
try {
|
|
1356
|
+
return this.readOnce();
|
|
1357
|
+
}
|
|
1358
|
+
finally {
|
|
1359
|
+
if (processReadCache.stats.fullReproofs > fullBefore)
|
|
1360
|
+
this.fullReproofThisTick = true;
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
readOnce() {
|
|
1364
|
+
return readVerifiedRecords(this.options.logPath, {
|
|
1365
|
+
...(this.options.schemaDir === undefined ? {} : { schemaDir: this.options.schemaDir }),
|
|
1366
|
+
publishSnapshot: this.options.snapshot !== false,
|
|
1367
|
+
// APRV-217. Absent on the options means absent here, which the read cache
|
|
1368
|
+
// reads as `full`: the daemon asks for the cheaper proof only when an
|
|
1369
|
+
// operator's policy or flag said so.
|
|
1370
|
+
...(this.options.readProof === undefined ? {} : { readProof: this.options.readProof }),
|
|
1371
|
+
});
|
|
1372
|
+
}
|
|
1373
|
+
/** The TTL in force right now, re-read every pass: policy files change. */
|
|
1374
|
+
ttlMs() {
|
|
1375
|
+
const where = this.options.policy.file !== undefined
|
|
1376
|
+
? { file: this.options.policy.file }
|
|
1377
|
+
: { dir: this.options.policy.dir ?? this.options.cwd };
|
|
1378
|
+
if (this.options.schemaDir !== undefined)
|
|
1379
|
+
where.schemaDir = this.options.schemaDir;
|
|
1380
|
+
const load = loadPolicy(where);
|
|
1381
|
+
// Fail closed exactly as the gate does: an unloadable policy declares no
|
|
1382
|
+
// TTL, so nothing lapses and nothing is expired on its behalf.
|
|
1383
|
+
return load.ok ? load.durations.approvalTtlMs : null;
|
|
1384
|
+
}
|
|
1385
|
+
gateOptions() {
|
|
1386
|
+
const options = { policy: this.options.policy };
|
|
1387
|
+
if (this.options.schemaDir !== undefined)
|
|
1388
|
+
options.schemaDir = this.options.schemaDir;
|
|
1389
|
+
if (this.options.clock !== undefined)
|
|
1390
|
+
options.clock = this.options.clock;
|
|
1391
|
+
return options;
|
|
1392
|
+
}
|
|
1393
|
+
// -------------------------------------------------------------------------
|
|
1394
|
+
// Envelope drift (SPEC.md §6.3)
|
|
1395
|
+
// -------------------------------------------------------------------------
|
|
1396
|
+
/**
|
|
1397
|
+
* Read every task file, compare its claimed `state:` against the log, and
|
|
1398
|
+
* append `envelope.drift` for each file that contradicts it.
|
|
1399
|
+
*
|
|
1400
|
+
* §6.3: "`state` is a projection of log events; the file is updated by the
|
|
1401
|
+
* daemon after the event is appended, never the reverse. A file edit that
|
|
1402
|
+
* contradicts the log is itself logged (`envelope.drift`) and surfaced."
|
|
1403
|
+
*
|
|
1404
|
+
* This scan **only records**. The repair is {@link writeBack}, later in the
|
|
1405
|
+
* same tick: the disagreement is written to the log first and copied into the
|
|
1406
|
+
* file second, in that order, so nothing is ever corrected off the record. A
|
|
1407
|
+
* drift record therefore names a moment, not a standing condition.
|
|
1408
|
+
*
|
|
1409
|
+
* A schema-invalid envelope is warned about and skipped, not logged as drift: a
|
|
1410
|
+
* malformed file is not a *contradiction* of the log, it is a file the runtime
|
|
1411
|
+
* cannot read a claim out of at all, and inventing a `declared_state` for it
|
|
1412
|
+
* would put a fact in the log that nobody wrote.
|
|
1413
|
+
*/
|
|
1414
|
+
scanForDrift() {
|
|
1415
|
+
// ONE verified read for the whole scan (APRV-211). The scan asks the same
|
|
1416
|
+
// question of every file — "what does the log say about this task?" — and
|
|
1417
|
+
// asking it per file re-verified and re-walked the log once per task file:
|
|
1418
|
+
// 210 reads a tick in this repository, 45% of a three-second tick.
|
|
1419
|
+
//
|
|
1420
|
+
// The read below is the DECISION's evidence. It never becomes an append's:
|
|
1421
|
+
// a file the decision finds in drift is re-derived against a fresh read
|
|
1422
|
+
// immediately before the append, and that fresh head is the `expectedHead`
|
|
1423
|
+
// the append is compared against (SPEC.md §11.1 invariant 5, unchanged).
|
|
1424
|
+
// Deciding from a slightly older log can therefore only cost a decision that
|
|
1425
|
+
// the fresh derivation then declines to act on; it can never place a record
|
|
1426
|
+
// against a head it did not see.
|
|
1427
|
+
const scan = this.read();
|
|
1428
|
+
if (!scan.ok)
|
|
1429
|
+
return { appended: 0, stop: this.fatal(scan) };
|
|
1430
|
+
let appended = 0;
|
|
1431
|
+
for (const file of this.taskFiles()) {
|
|
1432
|
+
const outcome = this.checkOneFile(file, scan);
|
|
1433
|
+
if (outcome.stop !== null)
|
|
1434
|
+
return { appended, stop: outcome.stop };
|
|
1435
|
+
if (outcome.appended)
|
|
1436
|
+
appended += 1;
|
|
1437
|
+
}
|
|
1438
|
+
return { appended, stop: null };
|
|
1439
|
+
}
|
|
1440
|
+
/** Every `*.md` under the task folder, sorted, non-recursive. */
|
|
1441
|
+
taskFiles() {
|
|
1442
|
+
let entries;
|
|
1443
|
+
try {
|
|
1444
|
+
entries = readdirSync(this.options.tasksDir, { withFileTypes: true });
|
|
1445
|
+
}
|
|
1446
|
+
catch (cause) {
|
|
1447
|
+
const code = cause.code;
|
|
1448
|
+
if (code !== "ENOENT") {
|
|
1449
|
+
this.warn("tasks-dir-unreadable", `task folder ${this.display(this.options.tasksDir)} could not be listed: ${errorMessage(cause)}; the next tick tries again`);
|
|
1450
|
+
}
|
|
1451
|
+
return [];
|
|
1452
|
+
}
|
|
1453
|
+
return entries
|
|
1454
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
|
|
1455
|
+
.map((entry) => join(this.options.tasksDir, entry.name))
|
|
1456
|
+
.sort();
|
|
1457
|
+
}
|
|
1458
|
+
checkOneFile(file, scan) {
|
|
1459
|
+
const read = readTaskFile(file);
|
|
1460
|
+
if (!read.ok) {
|
|
1461
|
+
if (read.code === "no-frontmatter") {
|
|
1462
|
+
// SPEC.md §6: a task with no envelope is valid markdown. Silent by
|
|
1463
|
+
// design — unless the log says this task once declared actions, which
|
|
1464
|
+
// makes it a loss rather than an absence (APRV-63). The file name is
|
|
1465
|
+
// the only id such a file leaves; the log decides whether it means
|
|
1466
|
+
// anything.
|
|
1467
|
+
const hint = taskIdFromFileName(file);
|
|
1468
|
+
if (hint === null)
|
|
1469
|
+
return { appended: false, stop: null };
|
|
1470
|
+
return this.reportEnvelopeLoss(file, hint, true, "no-frontmatter", scan);
|
|
1471
|
+
}
|
|
1472
|
+
this.warn(read.code === "io" ? "task-unreadable" : "frontmatter-invalid", `${this.display(file)}: ${read.message}`);
|
|
1473
|
+
return { appended: false, stop: null };
|
|
1474
|
+
}
|
|
1475
|
+
const envelope = read.data["approval"];
|
|
1476
|
+
if (envelope === undefined) {
|
|
1477
|
+
// Frontmatter, no `approval:` key. Ordinary for a task that never had
|
|
1478
|
+
// one; envelope loss for a task the log registered (APRV-63).
|
|
1479
|
+
const declaredId = read.data["id"];
|
|
1480
|
+
const id = typeof declaredId === "string" && declaredId.length > 0
|
|
1481
|
+
? declaredId
|
|
1482
|
+
: taskIdFromFileName(file);
|
|
1483
|
+
if (id === null)
|
|
1484
|
+
return { appended: false, stop: null };
|
|
1485
|
+
return this.reportEnvelopeLoss(file, id, typeof declaredId !== "string" || declaredId.length === 0, "no-approval-key", scan);
|
|
1486
|
+
}
|
|
1487
|
+
const id = read.data["id"];
|
|
1488
|
+
if (typeof id !== "string" || id.length === 0) {
|
|
1489
|
+
this.warn("task-id-missing", `${this.display(file)} carries an approval: envelope but no usable \`id\`; the task id is the key every log record is written under, so drift cannot be attributed to this file`);
|
|
1490
|
+
return { appended: false, stop: null };
|
|
1491
|
+
}
|
|
1492
|
+
// The read boundary, deliberately (APRV-148). This scan validates envelopes
|
|
1493
|
+
// it will never register: a pre-APRV-121 envelope carrying numeric monetary
|
|
1494
|
+
// fields was valid when its task was registered and must keep validating
|
|
1495
|
+
// here, or drift and loss detection turn silently off for exactly the
|
|
1496
|
+
// historical artifacts APRV-121's compatibility rule protects. `approval
|
|
1497
|
+
// register` still validates at the strict write boundary.
|
|
1498
|
+
const validation = validate("envelope", envelope, this.options.schemaDir === undefined
|
|
1499
|
+
? { mode: "historical" }
|
|
1500
|
+
: { schemaDir: this.options.schemaDir, mode: "historical" });
|
|
1501
|
+
if (!validation.ok) {
|
|
1502
|
+
this.warn("envelope-invalid", `${this.display(file)}: the approval: envelope failed envelope.schema.json (${validation.errors.length} error(s), first: ${validation.errors[0]?.message ?? "unknown"}); nothing was appended, because a malformed envelope makes no claim the log could contradict`);
|
|
1503
|
+
return { appended: false, stop: null };
|
|
1504
|
+
}
|
|
1505
|
+
const ts = readClock(this.options.clock === undefined ? {} : { clock: this.options.clock });
|
|
1506
|
+
const ttlMs = this.ttlMs();
|
|
1507
|
+
const declaredRaw = envelope.state;
|
|
1508
|
+
const declaredState = typeof declaredRaw === "string" ? declaredRaw : null;
|
|
1509
|
+
const envelopeDigest = digestOf(envelope);
|
|
1510
|
+
// The decision, from the scan's read.
|
|
1511
|
+
const decided = taskEnvelopeState(scan.records, id, ts, ttlMs);
|
|
1512
|
+
if (declaredState === decided.state)
|
|
1513
|
+
return { appended: false, stop: null };
|
|
1514
|
+
if (driftAlreadyLogged(scan.records, id, {
|
|
1515
|
+
declaredState,
|
|
1516
|
+
derivedState: decided.state,
|
|
1517
|
+
envelopeDigest,
|
|
1518
|
+
})) {
|
|
1519
|
+
return { appended: false, stop: null };
|
|
1520
|
+
}
|
|
1521
|
+
// Re-read immediately before appending, so the head this append is compared
|
|
1522
|
+
// against is the head the RECORDED fact was derived from. The whole decision
|
|
1523
|
+
// is remade against those fresh records: a log that moved between the scan
|
|
1524
|
+
// and here may have removed the drift (someone decided the request, another
|
|
1525
|
+
// writer recorded the same drift), and a record about a disagreement that no
|
|
1526
|
+
// longer exists is a record nobody wrote.
|
|
1527
|
+
const records = this.read();
|
|
1528
|
+
if (!records.ok)
|
|
1529
|
+
return { appended: false, stop: this.fatal(records) };
|
|
1530
|
+
const projection = taskEnvelopeState(records.records, id, ts, ttlMs);
|
|
1531
|
+
if (declaredState === projection.state)
|
|
1532
|
+
return { appended: false, stop: null };
|
|
1533
|
+
const facts = {
|
|
1534
|
+
declaredState,
|
|
1535
|
+
derivedState: projection.state,
|
|
1536
|
+
envelopeDigest,
|
|
1537
|
+
};
|
|
1538
|
+
if (driftAlreadyLogged(records.records, id, facts))
|
|
1539
|
+
return { appended: false, stop: null };
|
|
1540
|
+
const payload = {
|
|
1541
|
+
file: this.display(file),
|
|
1542
|
+
declared_state: declaredState,
|
|
1543
|
+
derived_state: facts.derivedState,
|
|
1544
|
+
registered: projection.registered,
|
|
1545
|
+
reason: "state-mismatch",
|
|
1546
|
+
};
|
|
1547
|
+
if (facts.envelopeDigest !== null)
|
|
1548
|
+
payload["envelope_sha256"] = facts.envelopeDigest;
|
|
1549
|
+
const result = appendEvent(this.options.logPath, {
|
|
1550
|
+
ts,
|
|
1551
|
+
event: "envelope.drift",
|
|
1552
|
+
actor: DAEMON_ACTOR,
|
|
1553
|
+
task: id,
|
|
1554
|
+
payload,
|
|
1555
|
+
}, {
|
|
1556
|
+
...(this.options.schemaDir === undefined ? {} : { schemaDir: this.options.schemaDir }),
|
|
1557
|
+
expectedHead: records.head,
|
|
1558
|
+
});
|
|
1559
|
+
if (!result.ok) {
|
|
1560
|
+
this.warn("append-refused", `envelope.drift for ${id} was not appended (${result.error.code}): ${result.error.message}`);
|
|
1561
|
+
return { appended: false, stop: null };
|
|
1562
|
+
}
|
|
1563
|
+
this.drifts += 1;
|
|
1564
|
+
this.emit({
|
|
1565
|
+
event: "drift",
|
|
1566
|
+
task: id,
|
|
1567
|
+
file: this.display(file),
|
|
1568
|
+
declared_state: declaredState,
|
|
1569
|
+
derived_state: facts.derivedState,
|
|
1570
|
+
seq: result.record.seq,
|
|
1571
|
+
});
|
|
1572
|
+
return { appended: true, stop: null };
|
|
1573
|
+
}
|
|
1574
|
+
/**
|
|
1575
|
+
* A task file with no envelope whose task the log registered: the envelope was
|
|
1576
|
+
* lost (APRV-63, the defense half of APRV-60).
|
|
1577
|
+
*
|
|
1578
|
+
* Recorded as `envelope.drift` with `payload.reason: "envelope-missing"` —
|
|
1579
|
+
* the same event type, because it is the same §6.3 question ("the file and
|
|
1580
|
+
* the log disagree"), and a distinct reason, because the answer is different:
|
|
1581
|
+
* a state mismatch is an edit to reconcile, a missing envelope is a deletion
|
|
1582
|
+
* to restore. `declared_state` is `null` because the file makes no claim at
|
|
1583
|
+
* all, and `envelope_sha256` is absent because there is no envelope to digest.
|
|
1584
|
+
*
|
|
1585
|
+
* **Nothing is repaired.** The registration in the log holds every action the
|
|
1586
|
+
* envelope declared, so a writer *could* re-emit it — and that would turn a
|
|
1587
|
+
* projection into a source, which is the one thing the log's authority rests
|
|
1588
|
+
* on not happening. The daemon reports; a human restores by hand.
|
|
1589
|
+
*
|
|
1590
|
+
* `loose` says the id came from the file name rather than from frontmatter;
|
|
1591
|
+
* it relaxes only the *matching*, and the record is written under the id the
|
|
1592
|
+
* log itself holds.
|
|
1593
|
+
*/
|
|
1594
|
+
reportEnvelopeLoss(file, id, loose, kind, scan) {
|
|
1595
|
+
// The decision, from the scan's read (APRV-211). This is the path the vast
|
|
1596
|
+
// majority of task files take — a plain Backlog.md task the log never
|
|
1597
|
+
// registered — and it used to cost one full verified read per file.
|
|
1598
|
+
const scanned = latestRegistration(scan.records, id, loose);
|
|
1599
|
+
if (scanned === null) {
|
|
1600
|
+
// The log has never heard of this task. SPEC.md §6: a task with no
|
|
1601
|
+
// envelope is valid markdown, and this one is exactly that.
|
|
1602
|
+
return { appended: false, stop: null };
|
|
1603
|
+
}
|
|
1604
|
+
const ts = readClock(this.options.clock === undefined ? {} : { clock: this.options.clock });
|
|
1605
|
+
const ttlMs = this.ttlMs();
|
|
1606
|
+
// Re-read immediately before appending, exactly as the mismatch path does:
|
|
1607
|
+
// the head this append is compared against is the head the recorded fact was
|
|
1608
|
+
// derived from, and the whole decision is remade against it.
|
|
1609
|
+
const records = this.read();
|
|
1610
|
+
if (!records.ok)
|
|
1611
|
+
return { appended: false, stop: this.fatal(records) };
|
|
1612
|
+
const registration = latestRegistration(records.records, id, loose);
|
|
1613
|
+
if (registration === null)
|
|
1614
|
+
return { appended: false, stop: null };
|
|
1615
|
+
const task = registration.task;
|
|
1616
|
+
if (typeof task !== "string" || task.length === 0)
|
|
1617
|
+
return { appended: false, stop: null };
|
|
1618
|
+
const projection = taskEnvelopeState(records.records, task, ts, ttlMs);
|
|
1619
|
+
const facts = {
|
|
1620
|
+
declaredState: null,
|
|
1621
|
+
derivedState: projection.state,
|
|
1622
|
+
envelopeDigest: null,
|
|
1623
|
+
reason: "envelope-missing",
|
|
1624
|
+
};
|
|
1625
|
+
if (driftAlreadyLogged(records.records, task, facts))
|
|
1626
|
+
return { appended: false, stop: null };
|
|
1627
|
+
const payload = {
|
|
1628
|
+
file: this.display(file),
|
|
1629
|
+
declared_state: null,
|
|
1630
|
+
derived_state: facts.derivedState,
|
|
1631
|
+
registered: projection.registered,
|
|
1632
|
+
reason: "envelope-missing",
|
|
1633
|
+
missing: kind,
|
|
1634
|
+
registered_seq: registration.seq,
|
|
1635
|
+
};
|
|
1636
|
+
const result = appendEvent(this.options.logPath, { ts, event: "envelope.drift", actor: DAEMON_ACTOR, task, payload }, {
|
|
1637
|
+
...(this.options.schemaDir === undefined ? {} : { schemaDir: this.options.schemaDir }),
|
|
1638
|
+
expectedHead: records.head,
|
|
1639
|
+
});
|
|
1640
|
+
if (!result.ok) {
|
|
1641
|
+
this.warn("append-refused", `envelope.drift (envelope-missing) for ${task} was not appended (${result.error.code}): ${result.error.message}`);
|
|
1642
|
+
return { appended: false, stop: null };
|
|
1643
|
+
}
|
|
1644
|
+
this.drifts += 1;
|
|
1645
|
+
this.emit({
|
|
1646
|
+
event: "drift",
|
|
1647
|
+
task,
|
|
1648
|
+
file: this.display(file),
|
|
1649
|
+
declared_state: null,
|
|
1650
|
+
derived_state: facts.derivedState,
|
|
1651
|
+
seq: result.record.seq,
|
|
1652
|
+
reason: "envelope-missing",
|
|
1653
|
+
});
|
|
1654
|
+
return { appended: true, stop: null };
|
|
1655
|
+
}
|
|
1656
|
+
// -------------------------------------------------------------------------
|
|
1657
|
+
// TTL sweep (SPEC.md §5.2, §6.3)
|
|
1658
|
+
// -------------------------------------------------------------------------
|
|
1659
|
+
/**
|
|
1660
|
+
* Append `approval.expired` for every live request whose TTL has lapsed.
|
|
1661
|
+
*
|
|
1662
|
+
* The sweep changes no verdict. `core/gate.ts` already judges the TTL lazily at
|
|
1663
|
+
* decision time, so a late grant is refused with or without an expiry record;
|
|
1664
|
+
* what the sweep adds is *visibility* — the queue, the index, and anyone
|
|
1665
|
+
* reading the log see a terminal fact rather than a request that looks live and
|
|
1666
|
+
* is not.
|
|
1667
|
+
*
|
|
1668
|
+
* Idempotent three ways over, and none of them is a remembered flag:
|
|
1669
|
+
*
|
|
1670
|
+
* - with lazy expiry, because `expire` refuses `already-decided` for anything a
|
|
1671
|
+
* human (or the lazy path) already settled;
|
|
1672
|
+
* - with itself, because the candidate list is re-derived from the verified log
|
|
1673
|
+
* each sweep and an expired request no longer appears in it;
|
|
1674
|
+
* - across restarts, because the daemon carries no state between runs at all.
|
|
1675
|
+
*
|
|
1676
|
+
* A `head-moved` refusal is expected traffic, not a fault: a CLI verb decided
|
|
1677
|
+
* the same request between this candidate list and this append. It is reported
|
|
1678
|
+
* at `debug` weight (a single warning line) and the next tick re-derives.
|
|
1679
|
+
*/
|
|
1680
|
+
sweepTtl() {
|
|
1681
|
+
const records = this.read();
|
|
1682
|
+
if (!records.ok)
|
|
1683
|
+
return { appended: 0, stop: this.fatal(records) };
|
|
1684
|
+
const ts = readClock(this.options.clock === undefined ? {} : { clock: this.options.clock });
|
|
1685
|
+
const candidates = lapsedRequests(records.records, ts, this.ttlMs());
|
|
1686
|
+
let appended = 0;
|
|
1687
|
+
for (const candidate of candidates) {
|
|
1688
|
+
const result = expire(this.options.logPath, candidate.actionKey, this.gateOptions());
|
|
1689
|
+
if (!result.ok) {
|
|
1690
|
+
// `not-expired` and `already-decided` mean the log moved under the
|
|
1691
|
+
// candidate list — someone decided it, or the clock the gate read differs
|
|
1692
|
+
// by a hair from the one this list was built with. Neither is an error,
|
|
1693
|
+
// and neither is retried here.
|
|
1694
|
+
this.warn("expire-refused", `approval.expired for ${candidate.actionKey} was not appended (${result.code}): ${result.message}`);
|
|
1695
|
+
continue;
|
|
1696
|
+
}
|
|
1697
|
+
appended += 1;
|
|
1698
|
+
this.expiries += 1;
|
|
1699
|
+
this.emit({
|
|
1700
|
+
event: "expired",
|
|
1701
|
+
action_key: candidate.actionKey,
|
|
1702
|
+
task: candidate.task,
|
|
1703
|
+
seq: result.record.seq,
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
return { appended, stop: null };
|
|
1707
|
+
}
|
|
1708
|
+
// -------------------------------------------------------------------------
|
|
1709
|
+
// Payload retention (amended SPEC.md §5.2, APRV-41)
|
|
1710
|
+
// -------------------------------------------------------------------------
|
|
1711
|
+
/**
|
|
1712
|
+
* Hand one pass to `daemon/prune.ts` and surface whatever it could not do.
|
|
1713
|
+
*
|
|
1714
|
+
* The daemon adds nothing to the rule: with `payload_retention` absent the
|
|
1715
|
+
* pass is a no-op, and with it present the pruner appends `payload.pruned`
|
|
1716
|
+
* before every unlink and re-derives the whole question from the verified log.
|
|
1717
|
+
* Warnings never stop the loop — a store that could not be pruned is a store
|
|
1718
|
+
* holding more evidence than the policy asked it to, which is the safe side.
|
|
1719
|
+
*/
|
|
1720
|
+
prune() {
|
|
1721
|
+
const options = {
|
|
1722
|
+
logPath: this.options.logPath,
|
|
1723
|
+
// The same resolution `ttlMs` uses: an unset directory means the daemon's
|
|
1724
|
+
// own working directory, never the process's.
|
|
1725
|
+
policy: this.options.policy.file !== undefined
|
|
1726
|
+
? { file: this.options.policy.file }
|
|
1727
|
+
: { dir: this.options.policy.dir ?? this.options.cwd },
|
|
1728
|
+
};
|
|
1729
|
+
if (this.options.schemaDir !== undefined)
|
|
1730
|
+
options.schemaDir = this.options.schemaDir;
|
|
1731
|
+
if (this.options.clock !== undefined)
|
|
1732
|
+
options.clock = this.options.clock;
|
|
1733
|
+
const report = prunePayloads(options);
|
|
1734
|
+
// Successes first, so the narrative reads in the order the pass ran them: a
|
|
1735
|
+
// prune that appended and could not unlink is a warning below, never a line
|
|
1736
|
+
// here (APRV-57).
|
|
1737
|
+
for (const done of report.pruned) {
|
|
1738
|
+
this.emit({
|
|
1739
|
+
event: "pruned",
|
|
1740
|
+
payload_hash: done.candidate.hash,
|
|
1741
|
+
reason: done.candidate.reason,
|
|
1742
|
+
action_key: done.candidate.actionKey,
|
|
1743
|
+
task: done.candidate.task,
|
|
1744
|
+
seq: done.seq,
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
for (const warning of report.warnings) {
|
|
1748
|
+
this.warn("prune-refused", `${warning.code}: ${warning.message}`);
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
// -------------------------------------------------------------------------
|
|
1752
|
+
// Projection write-back (SPEC.md §6.3, §10.2, APRV-62)
|
|
1753
|
+
// -------------------------------------------------------------------------
|
|
1754
|
+
/**
|
|
1755
|
+
* Rewrite every task file whose `state:` disagrees with the log, so the
|
|
1756
|
+
* projection says what the log says.
|
|
1757
|
+
*
|
|
1758
|
+
* §6.3: "`state` is a projection of log events; the file is updated by the
|
|
1759
|
+
* daemon after the event is appended, never the reverse." Both halves are
|
|
1760
|
+
* enforced structurally here. *After the event*: this runs at the end of the
|
|
1761
|
+
* tick, when the drift scan and the TTL sweep have appended everything they
|
|
1762
|
+
* are going to. *Never the reverse*: this method appends nothing at all, reads
|
|
1763
|
+
* the state it writes from `daemon/projection.ts`'s rollup over the verified
|
|
1764
|
+
* log, and produces bytes only through `core/task-file.ts`. A file can no more
|
|
1765
|
+
* teach the log a state than a screenshot can teach a database a row.
|
|
1766
|
+
*
|
|
1767
|
+
* Four rules, each of which is a way of not making things worse:
|
|
1768
|
+
*
|
|
1769
|
+
* 1. **Only files that already have an envelope.** `set-state` refuses
|
|
1770
|
+
* `no-envelope`, and that refusal is honoured silently: a task with no
|
|
1771
|
+
* `approval:` key is a plain Backlog.md task (SPEC.md §6 requires tolerating
|
|
1772
|
+
* it), and a daemon that gave one an envelope would be enrolling a task
|
|
1773
|
+
* nobody enrolled. The register path is where an envelope comes from.
|
|
1774
|
+
* 2. **No write when the bytes would not change.** The writer reports
|
|
1775
|
+
* `changed`, and the bytes are compared besides. An unnecessary write moves
|
|
1776
|
+
* an mtime, which wakes the watcher, which schedules a tick — a loop that
|
|
1777
|
+
* costs nothing but looks exactly like one that does not terminate.
|
|
1778
|
+
* 3. **A refusal leaves the file alone.** Anything the round-trip writer will
|
|
1779
|
+
* not do — corrupt YAML, an `approval:` key that is not a mapping, a
|
|
1780
|
+
* self-check that failed — becomes one `write-back-refused` warning carrying
|
|
1781
|
+
* the writer's own code. Nothing partial is ever written, because
|
|
1782
|
+
* `rewriteTaskFile` produces bytes or a refusal and `writeTaskFileAtomic`
|
|
1783
|
+
* renames a complete temp file into place.
|
|
1784
|
+
* 4. **Silence where the drift scan already spoke.** An unreadable file, a
|
|
1785
|
+
* frontmatter that does not parse, a missing `id`, a schema-invalid
|
|
1786
|
+
* envelope: each was warned about a few milliseconds ago by
|
|
1787
|
+
* {@link scanForDrift} over the same folder. Repeating it here would double
|
|
1788
|
+
* every line an operator reads without adding a fact.
|
|
1789
|
+
*
|
|
1790
|
+
* Loop safety comes from the comparison, not from a remembered flag: the next
|
|
1791
|
+
* tick derives the same state from the same log, finds the file already
|
|
1792
|
+
* declaring it, and does nothing — no drift, no write, no event. A file that
|
|
1793
|
+
* *keeps* needing repair is being rewritten by something else, and the drift
|
|
1794
|
+
* records are the trail of that fight.
|
|
1795
|
+
*/
|
|
1796
|
+
writeBack() {
|
|
1797
|
+
const files = this.taskFiles();
|
|
1798
|
+
if (files.length === 0)
|
|
1799
|
+
return null;
|
|
1800
|
+
const records = this.read();
|
|
1801
|
+
if (!records.ok)
|
|
1802
|
+
return this.fatal(records);
|
|
1803
|
+
const ts = readClock(this.options.clock === undefined ? {} : { clock: this.options.clock });
|
|
1804
|
+
const ttlMs = this.ttlMs();
|
|
1805
|
+
const rewriteOptions = this.options.schemaDir === undefined ? {} : { schemaDir: this.options.schemaDir };
|
|
1806
|
+
for (const file of files) {
|
|
1807
|
+
let text;
|
|
1808
|
+
try {
|
|
1809
|
+
text = readFileSync(file, "utf8");
|
|
1810
|
+
}
|
|
1811
|
+
catch {
|
|
1812
|
+
// Warned by the drift scan, or a file that vanished between the two
|
|
1813
|
+
// passes. Either way the next tick re-scans.
|
|
1814
|
+
continue;
|
|
1815
|
+
}
|
|
1816
|
+
const parsed = parseFrontmatter(text);
|
|
1817
|
+
if (!parsed.ok)
|
|
1818
|
+
continue;
|
|
1819
|
+
const envelope = parsed.data["approval"];
|
|
1820
|
+
if (typeof envelope !== "object" || envelope === null || Array.isArray(envelope))
|
|
1821
|
+
continue;
|
|
1822
|
+
const id = parsed.data["id"];
|
|
1823
|
+
if (typeof id !== "string" || id.length === 0)
|
|
1824
|
+
continue;
|
|
1825
|
+
// Read boundary, as in the drift scan above (APRV-148): a historical
|
|
1826
|
+
// envelope the scan just read a claim out of must also be repairable, or
|
|
1827
|
+
// the drift it records stands forever.
|
|
1828
|
+
const validation = validate("envelope", envelope, this.options.schemaDir === undefined
|
|
1829
|
+
? { mode: "historical" }
|
|
1830
|
+
: { schemaDir: this.options.schemaDir, mode: "historical" });
|
|
1831
|
+
if (!validation.ok)
|
|
1832
|
+
continue;
|
|
1833
|
+
const declaredRaw = envelope.state;
|
|
1834
|
+
const declaredState = typeof declaredRaw === "string" ? declaredRaw : null;
|
|
1835
|
+
const derived = taskEnvelopeState(records.records, id, ts, ttlMs).state;
|
|
1836
|
+
if (declaredState === derived)
|
|
1837
|
+
continue;
|
|
1838
|
+
const rewritten = rewriteTaskFile(text, { kind: "set-state", state: derived }, rewriteOptions);
|
|
1839
|
+
if (!rewritten.ok) {
|
|
1840
|
+
if (rewritten.code === "no-envelope")
|
|
1841
|
+
continue;
|
|
1842
|
+
this.warn("write-back-refused", `${this.display(file)}: the state: line could not be rewritten to ${derived} (${rewritten.code}): ${rewritten.message} The file is exactly as it was, the log is unchanged, and the envelope.drift record stands.`);
|
|
1843
|
+
continue;
|
|
1844
|
+
}
|
|
1845
|
+
if (!rewritten.changed || rewritten.bytes === text)
|
|
1846
|
+
continue;
|
|
1847
|
+
const written = writeTaskFileAtomic(file, rewritten.bytes);
|
|
1848
|
+
if (!written.ok) {
|
|
1849
|
+
this.warn("write-back-refused", `${this.display(file)}: the rewritten task file could not be placed (${written.code}): ${written.message}`);
|
|
1850
|
+
continue;
|
|
1851
|
+
}
|
|
1852
|
+
// The watcher is about to see this file change; the change was ours
|
|
1853
|
+
// (APRV-211). Recorded by basename because that is what `fs.watch` reports.
|
|
1854
|
+
this.selfWrites.add(basename(file));
|
|
1855
|
+
this.emit({
|
|
1856
|
+
event: "write_back",
|
|
1857
|
+
task: id,
|
|
1858
|
+
file: this.display(file),
|
|
1859
|
+
from: declaredState,
|
|
1860
|
+
to: derived,
|
|
1861
|
+
bytes: written.bytes,
|
|
1862
|
+
});
|
|
1863
|
+
}
|
|
1864
|
+
return null;
|
|
1865
|
+
}
|
|
1866
|
+
// -------------------------------------------------------------------------
|
|
1867
|
+
// Loop escalation (SPEC.md §10.2)
|
|
1868
|
+
// -------------------------------------------------------------------------
|
|
1869
|
+
/**
|
|
1870
|
+
* Report tasks that loop safety has escalated to manual, and tasks that have
|
|
1871
|
+
* come back.
|
|
1872
|
+
*
|
|
1873
|
+
* The projection is `core/loop.ts`'s and the enforcement is the gate's and the
|
|
1874
|
+
* executor's; this makes the state *visible* to whoever is watching the daemon,
|
|
1875
|
+
* which is the one thing neither of them does. `approval status` reports the
|
|
1876
|
+
* same set, from the same projection, for an operator who is not.
|
|
1877
|
+
*
|
|
1878
|
+
* Reported on change rather than every tick: a standing escalation restated
|
|
1879
|
+
* every interval is noise that trains an operator to scroll past it.
|
|
1880
|
+
*/
|
|
1881
|
+
surfaceEscalations(records) {
|
|
1882
|
+
const current = new Set();
|
|
1883
|
+
for (const state of loopEscalation(records)) {
|
|
1884
|
+
if (!state.escalated)
|
|
1885
|
+
continue;
|
|
1886
|
+
current.add(state.task);
|
|
1887
|
+
if (!this.reportedEscalations.has(state.task)) {
|
|
1888
|
+
this.emit({
|
|
1889
|
+
event: "escalated",
|
|
1890
|
+
task: state.task,
|
|
1891
|
+
consecutive_failures: state.consecutiveFailures,
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
for (const task of this.reportedEscalations) {
|
|
1896
|
+
if (!current.has(task))
|
|
1897
|
+
this.emit({ event: "escalation_cleared", task });
|
|
1898
|
+
}
|
|
1899
|
+
this.reportedEscalations = current;
|
|
1900
|
+
return current.size;
|
|
1901
|
+
}
|
|
1902
|
+
// -------------------------------------------------------------------------
|
|
1903
|
+
// The queue projection (SPEC.md §9.1)
|
|
1904
|
+
// -------------------------------------------------------------------------
|
|
1905
|
+
/**
|
|
1906
|
+
* Regenerate `QUEUE.md` through the real renderer.
|
|
1907
|
+
*
|
|
1908
|
+
* Never partial: `writeQueue` writes a temp file and renames it, so a reader
|
|
1909
|
+
* sees either the previous queue or the new one and never a half-written file,
|
|
1910
|
+
* and a crashed daemon leaves no torn queue behind.
|
|
1911
|
+
*
|
|
1912
|
+
* The file is rewritten every tick, because TTL countdowns move even when the
|
|
1913
|
+
* log does not. The *event* is emitted only when the summary changes, so a
|
|
1914
|
+
* standing queue does not fill an operator's terminal with identical lines.
|
|
1915
|
+
*/
|
|
1916
|
+
render() {
|
|
1917
|
+
const now = readClock(this.options.clock === undefined ? {} : { clock: this.options.clock });
|
|
1918
|
+
const result = writeQueue(this.options.logPath, this.options.queuePath, { policy: this.options.policy }, now);
|
|
1919
|
+
if (!result.ok) {
|
|
1920
|
+
this.warn("render-failed", `${this.display(this.options.queuePath)} was not regenerated (${result.code}): ${result.message}`);
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
this.renders += 1;
|
|
1924
|
+
const summary = {
|
|
1925
|
+
pending: result.pending,
|
|
1926
|
+
skipped: result.skipped,
|
|
1927
|
+
auditBacklog: result.auditBacklog,
|
|
1928
|
+
head: result.head === null ? null : result.head.seq,
|
|
1929
|
+
};
|
|
1930
|
+
const previous = this.lastRender;
|
|
1931
|
+
this.lastRender = summary;
|
|
1932
|
+
if (previous !== null &&
|
|
1933
|
+
previous.pending === summary.pending &&
|
|
1934
|
+
previous.skipped === summary.skipped &&
|
|
1935
|
+
previous.auditBacklog === summary.auditBacklog &&
|
|
1936
|
+
previous.head === summary.head) {
|
|
1937
|
+
return;
|
|
1938
|
+
}
|
|
1939
|
+
this.emit({
|
|
1940
|
+
event: "rendered",
|
|
1941
|
+
path: this.display(this.options.queuePath),
|
|
1942
|
+
bytes: result.bytes,
|
|
1943
|
+
pending: result.pending,
|
|
1944
|
+
skipped: result.skipped,
|
|
1945
|
+
audit_backlog: result.auditBacklog,
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
// -------------------------------------------------------------------------
|
|
1949
|
+
// Output helpers
|
|
1950
|
+
// -------------------------------------------------------------------------
|
|
1951
|
+
emit(event) {
|
|
1952
|
+
this.options.sink.emit(event);
|
|
1953
|
+
}
|
|
1954
|
+
warn(code, message) {
|
|
1955
|
+
this.emit({ event: "warning", code, message });
|
|
1956
|
+
}
|
|
1957
|
+
/** A path as the operator typed it: relative to cwd when it is inside it. */
|
|
1958
|
+
display(path) {
|
|
1959
|
+
const rel = relative(this.options.cwd, path);
|
|
1960
|
+
return rel.length > 0 && !rel.startsWith("..") && !isAbsolute(rel) ? rel : path;
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* SHA-256 over the RFC 8785 form of the envelope, part of the drift dedupe key.
|
|
1965
|
+
*
|
|
1966
|
+
* `null` when the value cannot be canonicalized. A YAML mapping that survived the
|
|
1967
|
+
* hardened parser and `envelope.schema.json` always can, so this is a backstop
|
|
1968
|
+
* rather than a live branch — and a backstop that degrades to "dedupe on the
|
|
1969
|
+
* state pair alone" rather than to a thrown error inside a watcher callback.
|
|
1970
|
+
*/
|
|
1971
|
+
function digestOf(envelope) {
|
|
1972
|
+
try {
|
|
1973
|
+
return payloadHash(envelope);
|
|
1974
|
+
}
|
|
1975
|
+
catch {
|
|
1976
|
+
return null;
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1979
|
+
/** Does this path exist and is it a directory? Used by the verb's preflight. */
|
|
1980
|
+
export function isDirectory(path) {
|
|
1981
|
+
try {
|
|
1982
|
+
return statSync(path).isDirectory();
|
|
1983
|
+
}
|
|
1984
|
+
catch {
|
|
1985
|
+
return false;
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
//# sourceMappingURL=daemon.js.map
|