approval-md 0.3.0 → 0.4.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.
Files changed (226) hide show
  1. package/README.md +83 -914
  2. package/SPEC.md +98 -13
  3. package/cli.js +66 -7
  4. package/dist/src/adapters/smtp.d.ts +18 -0
  5. package/dist/src/adapters/smtp.js +24 -3
  6. package/dist/src/adapters/smtp.js.map +1 -1
  7. package/dist/src/channels/contract.d.ts +19 -0
  8. package/dist/src/channels/contract.js +105 -1
  9. package/dist/src/channels/contract.js.map +1 -1
  10. package/dist/src/channels/relay-server.d.ts +90 -0
  11. package/dist/src/channels/relay-server.js +295 -0
  12. package/dist/src/channels/relay-server.js.map +1 -0
  13. package/dist/src/channels/relay.d.ts +273 -0
  14. package/dist/src/channels/relay.js +564 -0
  15. package/dist/src/channels/relay.js.map +1 -0
  16. package/dist/src/channels/tagging.js +6 -1
  17. package/dist/src/channels/tagging.js.map +1 -1
  18. package/dist/src/channels/telegram-webhook.d.ts +273 -0
  19. package/dist/src/channels/telegram-webhook.js +536 -0
  20. package/dist/src/channels/telegram-webhook.js.map +1 -0
  21. package/dist/src/channels/telegram.d.ts +271 -1
  22. package/dist/src/channels/telegram.js +403 -45
  23. package/dist/src/channels/telegram.js.map +1 -1
  24. package/dist/src/cli/amend.js +350 -4
  25. package/dist/src/cli/amend.js.map +1 -1
  26. package/dist/src/cli/attest.js +43 -6
  27. package/dist/src/cli/attest.js.map +1 -1
  28. package/dist/src/cli/channel-relay.d.ts +108 -0
  29. package/dist/src/cli/channel-relay.js +334 -0
  30. package/dist/src/cli/channel-relay.js.map +1 -0
  31. package/dist/src/cli/channel-telegram-webhook.d.ts +273 -0
  32. package/dist/src/cli/channel-telegram-webhook.js +709 -0
  33. package/dist/src/cli/channel-telegram-webhook.js.map +1 -0
  34. package/dist/src/cli/channel-telegram.d.ts +272 -26
  35. package/dist/src/cli/channel-telegram.js +478 -41
  36. package/dist/src/cli/channel-telegram.js.map +1 -1
  37. package/dist/src/cli/channel.d.ts +1 -1
  38. package/dist/src/cli/channel.js +5 -1
  39. package/dist/src/cli/channel.js.map +1 -1
  40. package/dist/src/cli/daemon.js +37 -5
  41. package/dist/src/cli/daemon.js.map +1 -1
  42. package/dist/src/cli/doctor.d.ts +63 -0
  43. package/dist/src/cli/doctor.js +856 -111
  44. package/dist/src/cli/doctor.js.map +1 -1
  45. package/dist/src/cli/execute.d.ts +17 -1
  46. package/dist/src/cli/execute.js +341 -20
  47. package/dist/src/cli/execute.js.map +1 -1
  48. package/dist/src/cli/exit-codes.d.ts +12 -0
  49. package/dist/src/cli/exit-codes.js +13 -0
  50. package/dist/src/cli/exit-codes.js.map +1 -1
  51. package/dist/src/cli/gate.d.ts +2 -0
  52. package/dist/src/cli/gate.js +233 -2
  53. package/dist/src/cli/gate.js.map +1 -1
  54. package/dist/src/cli/help.d.ts +9 -1
  55. package/dist/src/cli/help.js +246 -38
  56. package/dist/src/cli/help.js.map +1 -1
  57. package/dist/src/cli/hook.d.ts +371 -3
  58. package/dist/src/cli/hook.js +1926 -44
  59. package/dist/src/cli/hook.js.map +1 -1
  60. package/dist/src/cli/init.js +16 -7
  61. package/dist/src/cli/init.js.map +1 -1
  62. package/dist/src/cli/instructions.js +24 -3
  63. package/dist/src/cli/instructions.js.map +1 -1
  64. package/dist/src/cli/log-anchor.d.ts +55 -0
  65. package/dist/src/cli/log-anchor.js +199 -60
  66. package/dist/src/cli/log-anchor.js.map +1 -1
  67. package/dist/src/cli/main.d.ts +0 -12
  68. package/dist/src/cli/main.js +51 -0
  69. package/dist/src/cli/main.js.map +1 -1
  70. package/dist/src/cli/muse.d.ts +2 -0
  71. package/dist/src/cli/muse.js +62 -0
  72. package/dist/src/cli/muse.js.map +1 -0
  73. package/dist/src/cli/payload.js +79 -1
  74. package/dist/src/cli/payload.js.map +1 -1
  75. package/dist/src/cli/scaffold.js +6 -0
  76. package/dist/src/cli/scaffold.js.map +1 -1
  77. package/dist/src/cli/serve.d.ts +53 -0
  78. package/dist/src/cli/serve.js +270 -0
  79. package/dist/src/cli/serve.js.map +1 -0
  80. package/dist/src/cli/up.js +21 -2
  81. package/dist/src/cli/up.js.map +1 -1
  82. package/dist/src/cli/verb-registry.d.ts +2 -0
  83. package/dist/src/cli/verb-registry.js +337 -39
  84. package/dist/src/cli/verb-registry.js.map +1 -1
  85. package/dist/src/cli/wordmark.d.ts +2 -2
  86. package/dist/src/cli/wordmark.js +2 -2
  87. package/dist/src/core/attest-bootstrap.d.ts +95 -0
  88. package/dist/src/core/attest-bootstrap.js +135 -0
  89. package/dist/src/core/attest-bootstrap.js.map +1 -0
  90. package/dist/src/core/attest.d.ts +20 -3
  91. package/dist/src/core/attest.js +25 -0
  92. package/dist/src/core/attest.js.map +1 -1
  93. package/dist/src/core/audit.d.ts +55 -3
  94. package/dist/src/core/audit.js +52 -5
  95. package/dist/src/core/audit.js.map +1 -1
  96. package/dist/src/core/channel-lease.d.ts +322 -0
  97. package/dist/src/core/channel-lease.js +663 -0
  98. package/dist/src/core/channel-lease.js.map +1 -0
  99. package/dist/src/core/command-class.d.ts +51 -0
  100. package/dist/src/core/command-class.js +760 -8
  101. package/dist/src/core/command-class.js.map +1 -1
  102. package/dist/src/core/daemon-host.d.ts +178 -0
  103. package/dist/src/core/daemon-host.js +228 -0
  104. package/dist/src/core/daemon-host.js.map +1 -0
  105. package/dist/src/core/daemon-identity.d.ts +238 -0
  106. package/dist/src/core/daemon-identity.js +252 -0
  107. package/dist/src/core/daemon-identity.js.map +1 -0
  108. package/dist/src/core/gate.d.ts +306 -5
  109. package/dist/src/core/gate.js +772 -24
  110. package/dist/src/core/gate.js.map +1 -1
  111. package/dist/src/core/harness-version.d.ts +128 -4
  112. package/dist/src/core/harness-version.js +179 -12
  113. package/dist/src/core/harness-version.js.map +1 -1
  114. package/dist/src/core/harness-wait.d.ts +82 -3
  115. package/dist/src/core/harness-wait.js +89 -3
  116. package/dist/src/core/harness-wait.js.map +1 -1
  117. package/dist/src/core/log-subscribe.d.ts +21 -0
  118. package/dist/src/core/log-subscribe.js +16 -6
  119. package/dist/src/core/log-subscribe.js.map +1 -1
  120. package/dist/src/core/log-write-layer.d.ts +55 -0
  121. package/dist/src/core/log-write-layer.js +91 -0
  122. package/dist/src/core/log-write-layer.js.map +1 -0
  123. package/dist/src/core/log.d.ts +50 -21
  124. package/dist/src/core/log.js +197 -19
  125. package/dist/src/core/log.js.map +1 -1
  126. package/dist/src/core/payload-census.d.ts +51 -0
  127. package/dist/src/core/payload-census.js +125 -2
  128. package/dist/src/core/payload-census.js.map +1 -1
  129. package/dist/src/core/payload-store.d.ts +31 -0
  130. package/dist/src/core/payload-store.js +113 -8
  131. package/dist/src/core/payload-store.js.map +1 -1
  132. package/dist/src/core/payload.d.ts +18 -3
  133. package/dist/src/core/payload.js +20 -4
  134. package/dist/src/core/payload.js.map +1 -1
  135. package/dist/src/core/policy-diff.d.ts +10 -0
  136. package/dist/src/core/policy-diff.js +10 -2
  137. package/dist/src/core/policy-diff.js.map +1 -1
  138. package/dist/src/core/policy-explain.d.ts +7 -1
  139. package/dist/src/core/policy-explain.js +16 -1
  140. package/dist/src/core/policy-explain.js.map +1 -1
  141. package/dist/src/core/policy-load.d.ts +24 -0
  142. package/dist/src/core/policy-load.js.map +1 -1
  143. package/dist/src/core/policy-match.d.ts +38 -0
  144. package/dist/src/core/policy-match.js +35 -0
  145. package/dist/src/core/policy-match.js.map +1 -1
  146. package/dist/src/core/policy-proposal.d.ts +23 -0
  147. package/dist/src/core/policy-proposal.js +8 -2
  148. package/dist/src/core/policy-proposal.js.map +1 -1
  149. package/dist/src/core/read-scope.d.ts +10 -0
  150. package/dist/src/core/read-scope.js +4 -0
  151. package/dist/src/core/read-scope.js.map +1 -1
  152. package/dist/src/core/run-payload.d.ts +160 -0
  153. package/dist/src/core/run-payload.js +304 -0
  154. package/dist/src/core/run-payload.js.map +1 -0
  155. package/dist/src/core/sender-identity.d.ts +35 -10
  156. package/dist/src/core/sender-identity.js +48 -11
  157. package/dist/src/core/sender-identity.js.map +1 -1
  158. package/dist/src/core/state.d.ts +52 -1
  159. package/dist/src/core/state.js +52 -6
  160. package/dist/src/core/state.js.map +1 -1
  161. package/dist/src/core/telegram-config.d.ts +79 -0
  162. package/dist/src/core/telegram-config.js +108 -0
  163. package/dist/src/core/telegram-config.js.map +1 -1
  164. package/dist/src/core/verify.d.ts +23 -1
  165. package/dist/src/core/verify.js +20 -2
  166. package/dist/src/core/verify.js.map +1 -1
  167. package/dist/src/daemon/audit.d.ts +80 -5
  168. package/dist/src/daemon/audit.js +126 -6
  169. package/dist/src/daemon/audit.js.map +1 -1
  170. package/dist/src/daemon/daemon.d.ts +174 -2
  171. package/dist/src/daemon/daemon.js +298 -7
  172. package/dist/src/daemon/daemon.js.map +1 -1
  173. package/dist/src/daemon/git-evidence.d.ts +2 -2
  174. package/dist/src/daemon/git-evidence.js +1 -1
  175. package/dist/src/daemon/projection.d.ts +13 -2
  176. package/dist/src/daemon/projection.js +39 -6
  177. package/dist/src/daemon/projection.js.map +1 -1
  178. package/dist/src/mcp/server.d.ts +39 -3
  179. package/dist/src/mcp/server.js +22 -3
  180. package/dist/src/mcp/server.js.map +1 -1
  181. package/dist/src/muse/server.d.ts +16 -0
  182. package/dist/src/muse/server.js +238 -0
  183. package/dist/src/muse/server.js.map +1 -0
  184. package/dist/src/serve/archive.d.ts +158 -0
  185. package/dist/src/serve/archive.js +354 -0
  186. package/dist/src/serve/archive.js.map +1 -0
  187. package/dist/src/serve/arguments.d.ts +93 -0
  188. package/dist/src/serve/arguments.js +228 -0
  189. package/dist/src/serve/arguments.js.map +1 -0
  190. package/dist/src/serve/credentials.d.ts +91 -0
  191. package/dist/src/serve/credentials.js +143 -0
  192. package/dist/src/serve/credentials.js.map +1 -0
  193. package/dist/src/serve/follow.d.ts +86 -0
  194. package/dist/src/serve/follow.js +101 -0
  195. package/dist/src/serve/follow.js.map +1 -0
  196. package/dist/src/serve/hook-thread.d.ts +134 -0
  197. package/dist/src/serve/hook-thread.js +333 -0
  198. package/dist/src/serve/hook-thread.js.map +1 -0
  199. package/dist/src/serve/hook-worker.d.ts +134 -0
  200. package/dist/src/serve/hook-worker.js +173 -0
  201. package/dist/src/serve/hook-worker.js.map +1 -0
  202. package/dist/src/serve/server.d.ts +412 -0
  203. package/dist/src/serve/server.js +1251 -0
  204. package/dist/src/serve/server.js.map +1 -0
  205. package/docs/README-extended.md +766 -0
  206. package/docs/cli-reference.md +1633 -43
  207. package/package.json +4 -2
  208. package/schema/event.schema.json +59 -2
  209. package/schema/fixtures/event/invalid/daemon-id-malformed.json +15 -0
  210. package/schema/fixtures/event/invalid/execution-started-truncated-policy-sha256.json +18 -0
  211. package/schema/fixtures/event/invalid/harness-cap-at-margin.json +16 -0
  212. package/schema/fixtures/event/invalid/harness-cap-fractional.json +16 -0
  213. package/schema/fixtures/event/invalid/harness-cap-negative.json +16 -0
  214. package/schema/fixtures/event/invalid/harness-cap-string.json +16 -0
  215. package/schema/fixtures/event/invalid/harness-cap-without-harness-execution.json +16 -0
  216. package/schema/fixtures/event/invalid/harness-cap-zero.json +16 -0
  217. package/schema/fixtures/event/valid/approval-requested-harness-cap.json +18 -0
  218. package/schema/fixtures/event/valid/daemon-identity-declared.json +17 -0
  219. package/schema/fixtures/event/valid/daemon-identity.json +15 -0
  220. package/schema/fixtures/event/valid/execution-started-policy-sha256.json +18 -0
  221. package/schema/fixtures/event/valid/harness-kind-hermes.json +23 -0
  222. package/schema/fixtures/policy/invalid/daemons-malformed-id.json +10 -0
  223. package/schema/fixtures/policy/valid/daemons-allowlist.json +19 -0
  224. package/schema/fixtures/policy-md/invalid/schema-invalid-agent-may-request.md +21 -0
  225. package/schema/fixtures/policy-md/valid/agent-may-request.md +24 -0
  226. package/schema/policy.schema.json +50 -6
@@ -0,0 +1 @@
1
+ {"version":3,"file":"follow.js","sourceRoot":"","sources":["../../../src/serve/follow.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,oBAAoB,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAGtF,+DAA+D;AAC/D,MAAM,CAAC,MAAM,oBAAoB,GAAG,GAAG,CAAC;AAExC,mDAAmD;AACnD,MAAM,CAAC,MAAM,gBAAgB,GAAG,IAAI,CAAC;AAkCrC;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAAY;IAC5C,IAAI,IAAI,KAAK,WAAW;QAAE,OAAO,CAAC,CAAC;IACnC,IAAI,IAAI,KAAK,WAAW;QAAE,OAAO,CAAC,CAAC;IACnC,OAAO,CAAC,CAAC;AACX,CAAC;AAED;;;;;;;;;;GAUG;AAEH;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,OAAe,EACf,MAAoB,EACpB,KAAa;IAEb,MAAM,OAAO,GAAkB,EAAE,CAAC;IAClC,IAAI,QAAQ,GAAG,IAAI,CAAC;IAEpB,MAAM,OAAO,GAAG;QACd,IAAI,EAAE,MAAM,CAAC,GAAG;QAChB,IAAI,EAAE,IAAI;QACV,GAAG,CAAC,MAAM,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,YAAY,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC;KAC/D,CAAC;IAEF,IAAI,CAAC;QACH,IAAI,KAAK,EAAE,MAAM,MAAM,IAAI,oBAAoB,CAAC,OAAO,EAAE,OAAO,CAAC,EAAE,CAAC;YAClE,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YACrB,IAAI,OAAO,CAAC,MAAM,IAAI,KAAK,EAAE,CAAC;gBAC5B,uEAAuE;gBACvE,2CAA2C;gBAC3C,QAAQ,GAAG,KAAK,CAAC;gBACjB,MAAM;YACR,CAAC;QACH,CAAC;IACH,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAI,CAAC,CAAC,KAAK,YAAY,oBAAoB,CAAC;YAAE,MAAM,KAAK,CAAC;QAC1D,OAAO;YACL,EAAE,EAAE,KAAK;YACT,IAAI,EAAE,KAAK,CAAC,IAAI,KAAK,WAAW,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,WAAW;YACzF,OAAO,EAAE,KAAK,CAAC,OAAO;YACtB,MAAM,EAAE,KAAK,CAAC,MAAM;SACrB,CAAC;IACJ,CAAC;IAED,MAAM,IAAI,GAAG,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;IAC5B,OAAO;QACL,EAAE,EAAE,IAAI;QACR,IAAI,EAAE;YACJ,OAAO;YACP,MAAM,EAAE,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,IAAI,CAAC,GAAG,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE;YACxE,SAAS,EAAE,QAAQ;SACpB;KACF,CAAC;AACJ,CAAC"}
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Hook calls off the listener's main thread, holding the store lock only while
3
+ * they append (APRV-427).
4
+ *
5
+ * `serve/hook-worker.ts` holds the reasoning for the thread and the handshake.
6
+ * This is the listener's half: a small pool of warm worker threads, and the
7
+ * function that runs one hook call on one of them while taking and releasing
8
+ * the store's lock on the thread's behalf.
9
+ *
10
+ * ## What the lock covers for a hook call
11
+ *
12
+ * Every stretch in which the call may append: from its first line to the start
13
+ * of its poll loop (intake, the abandoned-question sweep, register, request),
14
+ * and from the end of the loop to its verdict (the spend, a withdrawal). Those
15
+ * are the stretches another in-process writer must not land inside. The loop
16
+ * itself only reads the verified log and sleeps, so while the call polls the
17
+ * lock is free and so is the main thread: other hooks open their own questions,
18
+ * and the tenant's reads answer.
19
+ *
20
+ * ## Why a pool
21
+ *
22
+ * A thread per call would work, and would pay two costs per call that a warm
23
+ * thread pays once: loading the CLI's modules, and proving the verified log
24
+ * from genesis on its first read (each thread has its own read cache, as each
25
+ * `approval hook` process does). Idle threads are kept up to
26
+ * {@link HOOK_THREADS_IDLE}; beyond that a finished thread is terminated.
27
+ *
28
+ * ## Bounded, because each thread is a copy of the log (APRV-427 review)
29
+ *
30
+ * A thread carries its own parsed log in its read cache: about 26 MB for an
31
+ * idle thread and another 84 MB once it has proved a 73k-record log. An
32
+ * uncapped pool let thirty concurrent gated calls, or one harness retrying
33
+ * faster than its hook timeout, grow the facade until the kernel killed it, and
34
+ * a kill that lands inside an append leaves the log's lockfile behind with
35
+ * nothing to recover it. So at most {@link HookThreadLimits.threads} calls run
36
+ * at once (default {@link DEFAULT_HOOK_THREADS}); up to
37
+ * {@link HookThreadLimits.queue} more wait for a slot in arrival order (default
38
+ * {@link DEFAULT_HOOK_QUEUE}); and a call past both is refused at once with
39
+ * {@link HookSaturatedError}, which the listener answers as a block
40
+ * (`serve-hook-saturated`). A queued call has opened nothing yet, so the wait
41
+ * costs the tenant nothing but time.
42
+ *
43
+ * Reusing a thread is what the listener did before this file existed: every
44
+ * hook call ran in the one main thread, one after another, with whatever
45
+ * module state the previous call left.
46
+ */
47
+ import type { HookWriter } from "./hook-worker.js";
48
+ /** How many finished hook threads are kept warm for the next call. */
49
+ export declare const HOOK_THREADS_IDLE = 4;
50
+ /** Hook calls that may run at once, and so threads that may exist, by default. */
51
+ export declare const DEFAULT_HOOK_THREADS = 16;
52
+ /** Hook calls that may wait for a thread, by default, before one is refused. */
53
+ export declare const DEFAULT_HOOK_QUEUE = 64;
54
+ /** The two bounds on the pool. */
55
+ export interface HookThreadLimits {
56
+ /** Calls running at once, and the most threads that ever exist. At least 1. */
57
+ threads: number;
58
+ /** Calls waiting for a slot. Zero refuses whatever finds every slot taken. */
59
+ queue: number;
60
+ }
61
+ /** A call refused because every slot and every place in the queue is taken. */
62
+ export declare class HookSaturatedError extends Error {
63
+ constructor(limits: HookThreadLimits);
64
+ /**
65
+ * The call waited so long for a slot, or for the lock, that the harness's
66
+ * ceiling leaves no window a human could answer in (APRV-427 review).
67
+ */
68
+ static outOfBudget(capMs: number, remainingMs: number): HookSaturatedError;
69
+ }
70
+ /** When a call arrived and the harness ceiling it arrived under, or `null`. */
71
+ export interface HookBudget {
72
+ arrivedAt: number;
73
+ capMs: number | null;
74
+ }
75
+ /**
76
+ * A call that will never be answered, because its client went away or the
77
+ * listener is closing. Nothing is sent for it.
78
+ */
79
+ export declare class HookCancelledError extends Error {
80
+ constructor(reason: string);
81
+ }
82
+ /** The pool's state, for diagnostics and the tests that bound it. */
83
+ export interface HookThreadStats {
84
+ /** Threads that exist (running or idle), not counting ones being terminated. */
85
+ threads: number;
86
+ /** Calls running on a thread. */
87
+ busy: number;
88
+ /** Calls waiting for a slot. */
89
+ queued: number;
90
+ /** The most threads that ever existed at once. */
91
+ peakThreads: number;
92
+ /** Calls whose thread has asked for the store lock and not yet been given it. */
93
+ awaitingLock: number;
94
+ /**
95
+ * Verified reads from genesis made while a thread held the store lock, over
96
+ * every call this pool has finished. The warm read keeps it at zero.
97
+ */
98
+ coldReadsInLock: number;
99
+ }
100
+ /** The store lock, as `mcp/server.ts`'s `serializer()` shapes one. */
101
+ export type StoreLock = <T>(work: () => Promise<T>) => Promise<T>;
102
+ /** What one hook call produced: the stdin form's exit code and both streams. */
103
+ export interface HookOutcome {
104
+ code: number;
105
+ stdout: string;
106
+ stderr: string;
107
+ }
108
+ export interface HookThreads {
109
+ /**
110
+ * Run `approval hook <argv>` with `body` as its stdin, under `lock`'s
111
+ * sections. `signal` aborts when the caller has gone away: a queued call
112
+ * leaves the line, and a running one stops at its next poll tick without
113
+ * spending or withdrawing (see `HookWaitSeam.cancelled`). Either way the
114
+ * promise may still settle with the hook's own output, which nobody is owed.
115
+ */
116
+ run(argv: string[], cwd: string, logPath: string, body: string, signal?: AbortSignal, budget?: HookBudget): Promise<HookOutcome>;
117
+ /**
118
+ * Shut the pool down: cancel every running call and every queued one, then
119
+ * terminate every thread, busy or idle, from inside the store lock so that
120
+ * none is killed in a mutation section. A call in flight rejects or settles
121
+ * with output nobody is owed.
122
+ */
123
+ close(): Promise<void>;
124
+ /** The pool's state now. */
125
+ stats(): HookThreadStats;
126
+ }
127
+ /**
128
+ * The pool. `writer` is the identity every thread declares and the policy each
129
+ * call refreshes the `daemons` allowlist from (APRV-448): required rather than
130
+ * optional, because a thread's module state is its own, and a pool that could be
131
+ * built without one would append records the listener's own thread stamps and
132
+ * these threads do not.
133
+ */
134
+ export declare function hookThreads(lock: StoreLock, writer: HookWriter, limits?: HookThreadLimits): HookThreads;
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Hook calls off the listener's main thread, holding the store lock only while
3
+ * they append (APRV-427).
4
+ *
5
+ * `serve/hook-worker.ts` holds the reasoning for the thread and the handshake.
6
+ * This is the listener's half: a small pool of warm worker threads, and the
7
+ * function that runs one hook call on one of them while taking and releasing
8
+ * the store's lock on the thread's behalf.
9
+ *
10
+ * ## What the lock covers for a hook call
11
+ *
12
+ * Every stretch in which the call may append: from its first line to the start
13
+ * of its poll loop (intake, the abandoned-question sweep, register, request),
14
+ * and from the end of the loop to its verdict (the spend, a withdrawal). Those
15
+ * are the stretches another in-process writer must not land inside. The loop
16
+ * itself only reads the verified log and sleeps, so while the call polls the
17
+ * lock is free and so is the main thread: other hooks open their own questions,
18
+ * and the tenant's reads answer.
19
+ *
20
+ * ## Why a pool
21
+ *
22
+ * A thread per call would work, and would pay two costs per call that a warm
23
+ * thread pays once: loading the CLI's modules, and proving the verified log
24
+ * from genesis on its first read (each thread has its own read cache, as each
25
+ * `approval hook` process does). Idle threads are kept up to
26
+ * {@link HOOK_THREADS_IDLE}; beyond that a finished thread is terminated.
27
+ *
28
+ * ## Bounded, because each thread is a copy of the log (APRV-427 review)
29
+ *
30
+ * A thread carries its own parsed log in its read cache: about 26 MB for an
31
+ * idle thread and another 84 MB once it has proved a 73k-record log. An
32
+ * uncapped pool let thirty concurrent gated calls, or one harness retrying
33
+ * faster than its hook timeout, grow the facade until the kernel killed it, and
34
+ * a kill that lands inside an append leaves the log's lockfile behind with
35
+ * nothing to recover it. So at most {@link HookThreadLimits.threads} calls run
36
+ * at once (default {@link DEFAULT_HOOK_THREADS}); up to
37
+ * {@link HookThreadLimits.queue} more wait for a slot in arrival order (default
38
+ * {@link DEFAULT_HOOK_QUEUE}); and a call past both is refused at once with
39
+ * {@link HookSaturatedError}, which the listener answers as a block
40
+ * (`serve-hook-saturated`). A queued call has opened nothing yet, so the wait
41
+ * costs the tenant nothing but time.
42
+ *
43
+ * Reusing a thread is what the listener did before this file existed: every
44
+ * hook call ran in the one main thread, one after another, with whatever
45
+ * module state the previous call left.
46
+ */
47
+ import { SHARE_ENV, Worker } from "node:worker_threads";
48
+ import { daemonIdentity } from "../core/daemon-identity.js";
49
+ import { HARNESS_CAP_MARGIN_MS, harnessCapFitsMargin } from "../core/harness-wait.js";
50
+ /** How many finished hook threads are kept warm for the next call. */
51
+ export const HOOK_THREADS_IDLE = 4;
52
+ /** Hook calls that may run at once, and so threads that may exist, by default. */
53
+ export const DEFAULT_HOOK_THREADS = 16;
54
+ /** Hook calls that may wait for a thread, by default, before one is refused. */
55
+ export const DEFAULT_HOOK_QUEUE = 64;
56
+ /** A call refused because every slot and every place in the queue is taken. */
57
+ export class HookSaturatedError extends Error {
58
+ constructor(limits) {
59
+ super(`every hook slot is busy (${String(limits.threads)} running, ${String(limits.queue)} queued); nothing was registered or requested. Retry the tool call later; the operator raises the bounds with --hook-threads and --hook-queue`);
60
+ this.name = "HookSaturatedError";
61
+ }
62
+ /**
63
+ * The call waited so long for a slot, or for the lock, that the harness's
64
+ * ceiling leaves no window a human could answer in (APRV-427 review).
65
+ */
66
+ static outOfBudget(capMs, remainingMs) {
67
+ const error = new HookSaturatedError({ threads: 0, queue: 0 });
68
+ error.message = `this call waited for a hook slot until only ${String(Math.max(0, remainingMs))}ms of its ${String(capMs)}ms harness ceiling was left, which does not clear the ${String(HARNESS_CAP_MARGIN_MS)}ms margin: a question opened now would still be pending when the harness kills the call. Nothing was registered or requested. Retry the tool call; the operator raises --hook-threads if calls queue this long`;
69
+ return error;
70
+ }
71
+ }
72
+ /**
73
+ * A call that will never be answered, because its client went away or the
74
+ * listener is closing. Nothing is sent for it.
75
+ */
76
+ export class HookCancelledError extends Error {
77
+ constructor(reason) {
78
+ super(reason);
79
+ this.name = "HookCancelledError";
80
+ }
81
+ }
82
+ /**
83
+ * The pool. `writer` is the identity every thread declares and the policy each
84
+ * call refreshes the `daemons` allowlist from (APRV-448): required rather than
85
+ * optional, because a thread's module state is its own, and a pool that could be
86
+ * built without one would append records the listener's own thread stamps and
87
+ * these threads do not.
88
+ */
89
+ export function hookThreads(lock, writer, limits = { threads: DEFAULT_HOOK_THREADS, queue: DEFAULT_HOOK_QUEUE }) {
90
+ const idle = [];
91
+ const all = new Set();
92
+ let closed = false;
93
+ /** Calls holding a slot. Never more than `limits.threads`. */
94
+ let busy = 0;
95
+ /** Calls waiting for a slot, oldest first. */
96
+ const waiting = [];
97
+ /** The cancel word of every call running on a thread. */
98
+ const running = new Set();
99
+ let peakThreads = 0;
100
+ let coldReadsInLock = 0;
101
+ let awaitingLock = 0;
102
+ /**
103
+ * Take a slot, waiting in line for one if every slot is held, or refuse.
104
+ *
105
+ * A thread is only ever spawned by a call holding a slot and finding no idle
106
+ * thread, so the threads that exist never outnumber the slots.
107
+ */
108
+ async function slot(signal) {
109
+ if (busy < limits.threads) {
110
+ busy += 1;
111
+ return;
112
+ }
113
+ if (waiting.length >= limits.queue)
114
+ throw new HookSaturatedError(limits);
115
+ // The slot is handed over by `freeSlot`, so `busy` is unchanged by it. A
116
+ // caller that leaves while in line leaves the line: it has opened nothing,
117
+ // and a slot spent on it would be a slot a caller who is still there waits
118
+ // for.
119
+ await new Promise((granted, dropped) => {
120
+ const entry = {
121
+ grant: () => {
122
+ signal?.removeEventListener("abort", leave);
123
+ granted();
124
+ },
125
+ drop: (cause) => {
126
+ signal?.removeEventListener("abort", leave);
127
+ dropped(cause);
128
+ },
129
+ };
130
+ function leave() {
131
+ const at = waiting.indexOf(entry);
132
+ if (at !== -1)
133
+ waiting.splice(at, 1);
134
+ entry.drop(new HookCancelledError("the caller went away while its hook call was queued"));
135
+ }
136
+ waiting.push(entry);
137
+ signal?.addEventListener("abort", leave, { once: true });
138
+ });
139
+ }
140
+ function freeSlot() {
141
+ const next = waiting.shift();
142
+ if (next !== undefined)
143
+ next.grant();
144
+ else
145
+ busy -= 1;
146
+ }
147
+ function retire(worker) {
148
+ all.delete(worker);
149
+ void worker.terminate();
150
+ }
151
+ function spawn() {
152
+ const worker = new Worker(new URL("./hook-worker.js", import.meta.url), {
153
+ // The launch environment, live, rather than a copy taken when the thread
154
+ // was born: the verb reads the environment the host started this process
155
+ // with (SPEC.md §11.1 invariant 7), and a warm thread must not answer
156
+ // from an older one.
157
+ env: SHARE_ENV,
158
+ // The identity the listener resolved at startup, declared by the thread
159
+ // before its first job (APRV-448). Resolved once, on the listener's
160
+ // thread, so a hook's records and a verb's carry one id.
161
+ workerData: writer,
162
+ });
163
+ // Neither an idle thread nor a busy one keeps the process alive on its own:
164
+ // the listener does that, and a closed listener leaves nothing behind.
165
+ worker.unref();
166
+ all.add(worker);
167
+ peakThreads = Math.max(peakThreads, all.size);
168
+ // A thread that dies while idle must not take the process with it through
169
+ // an unhandled `error`, nor be handed the next call.
170
+ worker.on("error", () => undefined);
171
+ worker.once("exit", () => {
172
+ all.delete(worker);
173
+ const at = idle.indexOf(worker);
174
+ if (at !== -1)
175
+ idle.splice(at, 1);
176
+ });
177
+ return worker;
178
+ }
179
+ function give(worker) {
180
+ if (!closed && idle.length < HOOK_THREADS_IDLE)
181
+ idle.push(worker);
182
+ else
183
+ retire(worker);
184
+ }
185
+ async function run(argv, cwd, logPath, body, signal, budget = { arrivedAt: Date.now(), capMs: null }) {
186
+ if (closed)
187
+ throw new HookCancelledError("the listener is closing");
188
+ if (signal?.aborted === true)
189
+ throw new HookCancelledError("the caller went away");
190
+ await slot(signal);
191
+ try {
192
+ // The time in line is spent out of the harness's budget (APRV-427
193
+ // review). A call that arrived with room and has none left is refused
194
+ // here, before a thread is spent on it. A ceiling that never had room is
195
+ // the hook's own refusal to make (`hook-harness-cap-too-short`), and an
196
+ // autonomous call under it is still answered.
197
+ const { capMs } = budget;
198
+ if (capMs !== null && harnessCapFitsMargin(capMs)) {
199
+ const remainingMs = capMs - Math.max(0, Date.now() - budget.arrivedAt);
200
+ if (!harnessCapFitsMargin(remainingMs)) {
201
+ throw HookSaturatedError.outOfBudget(capMs, remainingMs);
202
+ }
203
+ }
204
+ return await runOnThread(argv, cwd, logPath, body, signal, budget);
205
+ }
206
+ finally {
207
+ freeSlot();
208
+ }
209
+ }
210
+ async function runOnThread(argv, cwd, logPath, body, signal, budget) {
211
+ if (closed)
212
+ throw new HookCancelledError("the listener is closing");
213
+ if (signal?.aborted === true)
214
+ throw new HookCancelledError("the caller went away");
215
+ const worker = idle.pop() ?? spawn();
216
+ const shared = new SharedArrayBuffer(8);
217
+ const flag = new Int32Array(shared);
218
+ // The cancel word the hook's poll reads every tick. Set, never cleared: a
219
+ // call whose caller left is over whatever happens next.
220
+ const cancel = () => {
221
+ Atomics.store(flag, 1, 1);
222
+ };
223
+ signal?.addEventListener("abort", cancel, { once: true });
224
+ running.add(flag);
225
+ return await new Promise((settle, fail) => {
226
+ /** Ends the section the thread is in, when it is in one. */
227
+ let release = null;
228
+ let finished = false;
229
+ /** Take the store lock on the thread's behalf, then let it proceed. */
230
+ const enter = () => {
231
+ awaitingLock += 1;
232
+ void lock(async () => await new Promise((done) => {
233
+ awaitingLock -= 1;
234
+ // The thread died while this was queued: nothing to hand over.
235
+ if (finished) {
236
+ done();
237
+ return;
238
+ }
239
+ release = done;
240
+ Atomics.store(flag, 0, 1);
241
+ Atomics.notify(flag, 0);
242
+ }));
243
+ };
244
+ const leave = () => {
245
+ const done = release;
246
+ release = null;
247
+ done?.();
248
+ };
249
+ const finish = () => {
250
+ finished = true;
251
+ leave();
252
+ running.delete(flag);
253
+ signal?.removeEventListener("abort", cancel);
254
+ worker.off("message", onMessage);
255
+ worker.off("exit", onExit);
256
+ };
257
+ function onMessage(message) {
258
+ switch (message.type) {
259
+ case "wait":
260
+ leave();
261
+ return;
262
+ case "resume":
263
+ enter();
264
+ return;
265
+ case "done":
266
+ coldReadsInLock += message.coldReadsInLock;
267
+ finish();
268
+ give(worker);
269
+ settle({ code: message.code, stdout: message.stdout, stderr: message.stderr });
270
+ return;
271
+ case "failed":
272
+ finish();
273
+ give(worker);
274
+ fail(new Error(message.message));
275
+ return;
276
+ case "saturated":
277
+ finish();
278
+ give(worker);
279
+ fail(HookSaturatedError.outOfBudget(budget.capMs ?? 0, message.remainingMs));
280
+ return;
281
+ case "cancelled":
282
+ finish();
283
+ give(worker);
284
+ fail(new HookCancelledError("the caller went away before its hook call began"));
285
+ return;
286
+ }
287
+ }
288
+ function onExit(code) {
289
+ finish();
290
+ fail(new Error(`the hook thread exited (${String(code)}) before it answered`));
291
+ }
292
+ worker.on("message", onMessage);
293
+ worker.once("exit", onExit);
294
+ // The allowlist the listener holds in force right now (APRV-448 review):
295
+ // the thread never runs wider than this, even when its own resolution
296
+ // fails because the policy lost its attestation.
297
+ const allowed = daemonIdentity()?.allowed ?? null;
298
+ const job = { argv, cwd, body, logPath, budget, allowed, flag: shared };
299
+ // The thread warms its read cache first and then asks for its first
300
+ // section with `resume`, so no cold walk of the log is ever made inside
301
+ // the lock (see `hook-worker.ts`).
302
+ worker.postMessage(job);
303
+ });
304
+ }
305
+ return {
306
+ run,
307
+ stats: () => ({ threads: all.size, busy, queued: waiting.length, peakThreads, coldReadsInLock, awaitingLock }),
308
+ close: async () => {
309
+ closed = true;
310
+ // Every running call stops at its next poll tick without spending or
311
+ // withdrawing, exactly as for a client that went away; nobody in line
312
+ // will be served.
313
+ for (const flag of running)
314
+ Atomics.store(flag, 1, 1);
315
+ for (const entry of waiting.splice(0)) {
316
+ entry.drop(new HookCancelledError("the listener is closing"));
317
+ }
318
+ const resting = idle.splice(0);
319
+ await Promise.all(resting.map(async (worker) => await worker.terminate()));
320
+ // A busy thread is terminated only from INSIDE the store lock. Holding it
321
+ // means no thread is in a mutation section, so none is between taking the
322
+ // log's append lockfile and releasing it: a thread killed there would
323
+ // leave the lockfile behind and every later append would time out on it.
324
+ // A thread in its wait holds neither, and a question it opened stays open
325
+ // for the retry grace, exactly as for an `approval hook` process that was
326
+ // killed mid-wait.
327
+ await lock(async () => {
328
+ await Promise.all([...all].map(async (worker) => await worker.terminate()));
329
+ });
330
+ },
331
+ };
332
+ }
333
+ //# sourceMappingURL=hook-thread.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hook-thread.js","sourceRoot":"","sources":["../../../src/serve/hook-thread.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The thread a `POST /hook/<harness>` call runs on (APRV-427).
3
+ *
4
+ * A harness hook is synchronous from its first line to its verdict, and its
5
+ * wait sleeps with `Atomics.wait`. Run on the listener's main thread, one hook
6
+ * waiting on a human froze the event loop for the whole of that wait: no socket
7
+ * was read, so the tenant's `GET /status` and `POST /verb/queue` sat unanswered
8
+ * until the question was decided, and N gated calls from one sandbox queued
9
+ * behind each other. Scoping a lock cannot fix a thread that does not run, so
10
+ * the call runs here instead and the main thread stays free.
11
+ *
12
+ * This file runs the hook and nothing else. The verdict is `commandHook`'s, with
13
+ * the request body as the stdin it would have read, exactly as before; what is
14
+ * added is the {@link HookWaitSeam}, which is how this thread hands the store
15
+ * lock back to the listener while the hook polls and asks for it again before
16
+ * the hook appends.
17
+ *
18
+ * ## The handshake
19
+ *
20
+ * The lock lives on the main thread (`serve/server.ts`), because every other
21
+ * writer in the process takes it there. This thread holds it BY PROXY: the
22
+ * main thread acquires it on this thread's behalf and flips a shared flag.
23
+ *
24
+ * - A job arrives with the flag at 0. This thread first reads the verified log
25
+ * ONCE, outside the lock, to warm its own read cache (below), then posts
26
+ * `resume` and blocks on the flag before it runs a line of the hook, so
27
+ * intake, the sweep, register and request all happen under the lock.
28
+ * - `enterWait` clears the flag FIRST and then posts `wait`: the main thread
29
+ * releases the lock, and a later `leaveWait` cannot be satisfied by the grant
30
+ * that has just ended.
31
+ * - `leaveWait` posts `resume` and blocks on the flag until the main thread has
32
+ * re-acquired the lock and set it to 1.
33
+ * - `done` carries the exit code and both streams, and ends the last section.
34
+ *
35
+ * A blocked `Atomics.wait` here costs this thread and nothing else, which is
36
+ * the point of the thread.
37
+ *
38
+ * ## The warm read, outside the lock (APRV-427 review)
39
+ *
40
+ * A new thread's cache is empty, and its first verified read walks the whole
41
+ * chain: 633 ms on a mature tenant log. Taken inside the lock, five threads
42
+ * arriving together serialised five cold walks and held the tenant's `status`
43
+ * behind all of them. So the cold walk happens here, before the lock is asked
44
+ * for, and in parallel with every other thread's. The hook's own reads under
45
+ * the lock are then warm: a hash over the prefix this thread already proved,
46
+ * plus a walk of whatever was appended since. Nothing is decided from the warm
47
+ * read itself, and nothing is appended on the strength of it; every append
48
+ * inside the lock is still compare-and-append against a head read inside the
49
+ * lock (SPEC.md §11.1 invariant 5).
50
+ */
51
+ import { type DaemonIdResolution } from "../core/daemon-host.js";
52
+ import type { LoadPolicyOptions } from "../core/policy-load.js";
53
+ /**
54
+ * What every thread in the pool writes as (APRV-448), handed over once as the
55
+ * thread's `workerData`.
56
+ *
57
+ * A worker thread holds its own copy of every module, so the identity the
58
+ * listener declared on its own thread is not declared here: without this, a
59
+ * hook call's records would carry no `daemon` field and meet no `daemons`
60
+ * allowlist while a verb call's beside them did. The listener resolves the id
61
+ * ONCE, from its launch environment and its log path, and every thread declares
62
+ * that same resolution; no request carries any part of it.
63
+ */
64
+ export interface HookWriter {
65
+ /** The listener's own resolution, declared verbatim. */
66
+ daemon: DaemonIdResolution;
67
+ /** Where the store's policy is, for the per-call allowlist refresh. */
68
+ policy: LoadPolicyOptions;
69
+ }
70
+ /** One hook call, as the listener hands it over. */
71
+ export interface HookJob {
72
+ argv: string[];
73
+ cwd: string;
74
+ body: string;
75
+ /** The store's log, for the warm read before the lock. */
76
+ logPath: string;
77
+ /**
78
+ * When the call arrived (epoch ms, the listener's clock, which is this
79
+ * thread's too) and the harness ceiling it arrived under, or `null` where
80
+ * the harness has none. The remainder is computed ONCE, when the lock
81
+ * arrives, and the hook is handed that exact number.
82
+ */
83
+ budget: {
84
+ arrivedAt: number;
85
+ capMs: number | null;
86
+ };
87
+ /**
88
+ * The `daemons` allowlist the listener holds in force when it hands the call
89
+ * over (APRV-448 review), or `null` for no restriction. The thread starts
90
+ * each call from the stricter of this and its own, so a thread whose own
91
+ * resolution fails (a policy edited and not re-attested) never runs wider
92
+ * than the listener beside it.
93
+ */
94
+ allowed: readonly string[] | null;
95
+ /**
96
+ * Two `Int32`s. `[0]` is 1 while this thread holds the store lock by proxy,
97
+ * else 0. `[1]` is set to 1 by the listener when the HTTP client that asked
98
+ * has gone away (or the listener is closing), and read by the hook's poll as
99
+ * {@link HookWaitSeam.cancelled}.
100
+ */
101
+ flag: SharedArrayBuffer;
102
+ }
103
+ /** What this thread posts back. */
104
+ export type HookWorkerMessage = {
105
+ type: "wait";
106
+ } | {
107
+ type: "resume";
108
+ } | {
109
+ type: "done";
110
+ code: number;
111
+ stdout: string;
112
+ stderr: string;
113
+ /**
114
+ * Verified reads from genesis this call made while holding the store
115
+ * lock. Zero is the property the warm read exists for; the listener
116
+ * sums it into its diagnostics so a test can assert it without timing.
117
+ */
118
+ coldReadsInLock: number;
119
+ } | {
120
+ type: "failed";
121
+ message: string;
122
+ }
123
+ /** The caller went away before the hook began: nothing was run or appended. */
124
+ | {
125
+ type: "cancelled";
126
+ }
127
+ /**
128
+ * The call's harness budget ran below the margin while it waited for the
129
+ * lock, so no question it opened could be held: nothing was run or appended.
130
+ */
131
+ | {
132
+ type: "saturated";
133
+ remainingMs: number;
134
+ };