firedrill-run 2.2.5 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (392) hide show
  1. package/README.md +176 -22
  2. package/bin/firedrill.mjs +29 -5
  3. package/dist/assembly.json +13 -0
  4. package/dist/cli/runtime/candidate_copy.py +395 -0
  5. package/dist/cli/runtime/source_snapshot.py +391 -0
  6. package/dist/cli/src/administration.js +295 -0
  7. package/dist/cli/src/agent-changes.js +496 -0
  8. package/dist/cli/src/assessment-requests.js +67 -0
  9. package/dist/cli/src/auth.js +506 -0
  10. package/dist/cli/src/billing-commerce-contracts.js +1 -0
  11. package/dist/cli/src/billing-usage.js +227 -0
  12. package/dist/cli/src/billing-wallet-contracts.js +1 -0
  13. package/dist/cli/src/billing.js +170 -0
  14. package/dist/cli/src/bootstrap-process-lock.js +136 -0
  15. package/dist/cli/src/browser-links.js +14 -0
  16. package/dist/cli/src/campaign-controls.js +122 -0
  17. package/dist/cli/src/campaign-diagnoses.js +118 -0
  18. package/dist/cli/src/campaign-reports.js +1215 -0
  19. package/dist/cli/src/campaign-world.js +156 -0
  20. package/dist/cli/src/candidate-copy.js +376 -0
  21. package/dist/cli/src/catalog-contracts.js +1 -0
  22. package/dist/cli/src/ci-access-contracts.js +1 -0
  23. package/dist/cli/src/ci-configuration-contracts.js +1 -0
  24. package/dist/cli/src/ci-coverage.js +64 -0
  25. package/dist/cli/src/ci-delivery-contracts.js +1 -0
  26. package/dist/cli/src/ci-result-contracts.js +1 -0
  27. package/dist/cli/src/ci-run-contracts.js +1 -0
  28. package/dist/cli/src/ci-setup-proposals.js +52 -0
  29. package/dist/cli/src/company-policies.js +229 -0
  30. package/dist/cli/src/completion.js +79 -0
  31. package/dist/cli/src/configuration-intents.js +277 -0
  32. package/dist/cli/src/configuration.js +369 -0
  33. package/dist/cli/src/continuous-simulation.js +277 -0
  34. package/dist/cli/src/coordinated-checkpoints.js +133 -0
  35. package/dist/cli/src/credential-mode.js +139 -0
  36. package/dist/cli/src/credentials.js +331 -0
  37. package/dist/cli/src/credit-format.js +8 -0
  38. package/dist/cli/src/dataset-contracts.js +1 -0
  39. package/dist/cli/src/dataset-export.js +154 -0
  40. package/dist/cli/src/dataset-reads.js +37 -0
  41. package/dist/cli/src/dependency-preparation.js +239 -0
  42. package/dist/cli/src/director-tool-setup.js +104 -0
  43. package/dist/cli/src/driver-review.js +225 -0
  44. package/dist/cli/src/evaluation-authoring.js +315 -0
  45. package/dist/cli/src/evidence-bundles.js +367 -0
  46. package/dist/cli/src/execution-proposals.js +36 -0
  47. package/dist/cli/src/http.js +302 -0
  48. package/dist/cli/src/local-container-boundary.js +38 -0
  49. package/dist/cli/src/local-container-policy.js +201 -0
  50. package/dist/cli/src/local-execution-setup.js +81 -0
  51. package/dist/cli/src/local-source-basis.js +73 -0
  52. package/dist/cli/src/local-source.js +66 -0
  53. package/dist/cli/src/local-value-store.js +91 -0
  54. package/dist/cli/src/local-values.js +163 -0
  55. package/dist/cli/src/login-organizations.js +70 -0
  56. package/dist/cli/src/main.js +1627 -0
  57. package/dist/cli/src/mcp-connection.js +109 -0
  58. package/dist/cli/src/native/bindings.js +137 -0
  59. package/dist/cli/src/native/canonical.js +370 -0
  60. package/dist/cli/src/native/cloud-resources.js +942 -0
  61. package/dist/cli/src/native/companion-channel.js +421 -0
  62. package/dist/cli/src/native/companion-ipc.js +473 -0
  63. package/dist/cli/src/native/contracts.js +218 -0
  64. package/dist/cli/src/native/control-plane.js +1354 -0
  65. package/dist/cli/src/native/credit-approval.js +15 -0
  66. package/dist/cli/src/native/dependency-proxy.js +1006 -0
  67. package/dist/cli/src/native/drill-cleanup.js +115 -0
  68. package/dist/cli/src/native/drill-runner.js +1562 -0
  69. package/dist/cli/src/native/drivers/action-trace.js +210 -0
  70. package/dist/cli/src/native/drivers/function-wrapper.js +152 -0
  71. package/dist/cli/src/native/drivers/http.js +442 -0
  72. package/dist/cli/src/native/drivers/index.js +47 -0
  73. package/dist/cli/src/native/drivers/jsonl-process.js +538 -0
  74. package/dist/cli/src/native/drivers/playwright.js +600 -0
  75. package/dist/cli/src/native/drivers/signed-event.js +244 -0
  76. package/dist/cli/src/native/drivers/types.js +578 -0
  77. package/dist/cli/src/native/evidence-filter.js +255 -0
  78. package/dist/cli/src/native/isolated-access.js +241 -0
  79. package/dist/cli/src/native/library-sharing.js +220 -0
  80. package/dist/cli/src/native/local-run.js +667 -0
  81. package/dist/cli/src/native/local-secrets.js +19 -0
  82. package/dist/cli/src/native/mcp-config.js +123 -0
  83. package/dist/cli/src/native/metadata-requests.js +120 -0
  84. package/dist/cli/src/native/observations.js +1159 -0
  85. package/dist/cli/src/native/owned-groups.js +46 -0
  86. package/dist/cli/src/native/plan.js +594 -0
  87. package/dist/cli/src/native/png.js +159 -0
  88. package/dist/cli/src/native/ports.js +43 -0
  89. package/dist/cli/src/native/readiness.js +139 -0
  90. package/dist/cli/src/native/reassessments.js +206 -0
  91. package/dist/cli/src/native/redaction.js +235 -0
  92. package/dist/cli/src/native/registration.js +155 -0
  93. package/dist/cli/src/native/roots.js +110 -0
  94. package/dist/cli/src/native/run-cleanup.js +199 -0
  95. package/dist/cli/src/native/scratch.js +117 -0
  96. package/dist/cli/src/native/screenshots.js +60 -0
  97. package/dist/cli/src/native/session.js +556 -0
  98. package/dist/cli/src/native/standalone-proxy.js +580 -0
  99. package/dist/cli/src/native/standalone-renewals.js +183 -0
  100. package/dist/cli/src/native/standalone-state.js +193 -0
  101. package/dist/cli/src/native/standalone-tools.js +187 -0
  102. package/dist/cli/src/native/supervisor.js +831 -0
  103. package/dist/cli/src/native/target-auth.js +161 -0
  104. package/dist/cli/src/native/target-state.js +330 -0
  105. package/dist/cli/src/native/tool-reuse.js +364 -0
  106. package/dist/cli/src/native/tool-setups.js +336 -0
  107. package/dist/cli/src/native/world-reuse.js +288 -0
  108. package/dist/cli/src/onboarding.js +23 -0
  109. package/dist/cli/src/priority-requests.js +70 -0
  110. package/dist/cli/src/priority.js +223 -0
  111. package/dist/cli/src/prompts.js +184 -0
  112. package/dist/cli/src/real-operations.js +221 -0
  113. package/dist/cli/src/recipe-intents.js +58 -0
  114. package/dist/cli/src/recorded-context-intents.js +59 -0
  115. package/dist/cli/src/recorded-context.js +248 -0
  116. package/dist/cli/src/recordings.js +69 -0
  117. package/dist/cli/src/registration-guide.js +39 -0
  118. package/dist/cli/src/report-exports.js +213 -0
  119. package/dist/cli/src/request-input.js +47 -0
  120. package/dist/cli/src/resource-lifecycle.js +141 -0
  121. package/dist/cli/src/revision-requests.js +55 -0
  122. package/dist/cli/src/runner-abandon.js +53 -0
  123. package/dist/cli/src/runner-campaign-controls.js +262 -0
  124. package/dist/cli/src/runner-claim-reads.js +36 -0
  125. package/dist/cli/src/runner-completion.js +65 -0
  126. package/dist/cli/src/runner-configuration.js +546 -0
  127. package/dist/cli/src/runner-container.js +794 -0
  128. package/dist/cli/src/runner-evidence-erasure.js +351 -0
  129. package/dist/cli/src/runner-evidence-privacy.js +37 -0
  130. package/dist/cli/src/runner-evidence-retention.js +161 -0
  131. package/dist/cli/src/runner-evidence.js +326 -0
  132. package/dist/cli/src/runner-execute.js +92 -0
  133. package/dist/cli/src/runner-execution-deadline.js +25 -0
  134. package/dist/cli/src/runner-grants.js +307 -0
  135. package/dist/cli/src/runner-identity.js +615 -0
  136. package/dist/cli/src/runner-inputs.js +308 -0
  137. package/dist/cli/src/runner-jobs.js +49 -0
  138. package/dist/cli/src/runner-model-transport.js +392 -0
  139. package/dist/cli/src/runner-ownership.js +332 -0
  140. package/dist/cli/src/runner-process-lock.js +175 -0
  141. package/dist/cli/src/runner-runtime.js +396 -0
  142. package/dist/cli/src/runner-session-channel.js +377 -0
  143. package/dist/cli/src/runner-trial-completion.js +34 -0
  144. package/dist/cli/src/runner-trial-journal.js +358 -0
  145. package/dist/cli/src/runner-trial-live.js +393 -0
  146. package/dist/cli/src/runner-trial-protocol.js +289 -0
  147. package/dist/cli/src/runner-trial-sync.js +110 -0
  148. package/dist/cli/src/runner-trial-transfer.js +107 -0
  149. package/dist/cli/src/runner-voice.js +40 -0
  150. package/dist/cli/src/runner-world-claim.js +21 -0
  151. package/dist/cli/src/runner-world-collector.js +159 -0
  152. package/dist/cli/src/runner-world-command-evidence.js +487 -0
  153. package/dist/cli/src/runner-world-commands.js +268 -0
  154. package/dist/cli/src/runner-world-completion.js +76 -0
  155. package/dist/cli/src/runner-world-erasure.js +95 -0
  156. package/dist/cli/src/runner-world-evidence-sync.js +91 -0
  157. package/dist/cli/src/runner-world-journal.js +510 -0
  158. package/dist/cli/src/runner-world-native-filters.js +134 -0
  159. package/dist/cli/src/runner-world-observations.js +50 -0
  160. package/dist/cli/src/runner-world-session-protocol.js +134 -0
  161. package/dist/cli/src/saved-records.js +40 -0
  162. package/dist/cli/src/scenario-authoring.js +212 -0
  163. package/dist/cli/src/scenario-discovery.js +216 -0
  164. package/dist/cli/src/scenario-paths.js +68 -0
  165. package/dist/cli/src/scenario-requests.js +4 -0
  166. package/dist/cli/src/service-receipts.js +105 -0
  167. package/dist/cli/src/simple/authoring.js +270 -0
  168. package/dist/cli/src/simple/credit-approval.js +16 -0
  169. package/dist/cli/src/simple/help.js +98 -0
  170. package/dist/cli/src/simple/init.js +96 -0
  171. package/dist/cli/src/simple/library-sharing.js +414 -0
  172. package/dist/cli/src/simple/metadata-requests.js +46 -0
  173. package/dist/cli/src/simple/native-report.js +841 -0
  174. package/dist/cli/src/simple/native-tools.js +780 -0
  175. package/dist/cli/src/simple/plan-apply.js +506 -0
  176. package/dist/cli/src/simple/reassessment.js +323 -0
  177. package/dist/cli/src/simple/register.js +123 -0
  178. package/dist/cli/src/simple/run.js +122 -0
  179. package/dist/cli/src/simple/status.js +49 -0
  180. package/dist/cli/src/simple/tool-renewal.js +308 -0
  181. package/dist/cli/src/simple/tool-reuse.js +526 -0
  182. package/dist/cli/src/simple/tool-start-catalog.js +100 -0
  183. package/dist/cli/src/simple/tool-state.js +426 -0
  184. package/dist/cli/src/simple/tools-author.js +222 -0
  185. package/dist/cli/src/simple/watch.js +28 -0
  186. package/dist/cli/src/simple/world-reuse.js +586 -0
  187. package/dist/cli/src/simulation-intents.js +406 -0
  188. package/dist/cli/src/simulation-run.js +1058 -0
  189. package/dist/cli/src/source-batch.js +322 -0
  190. package/dist/cli/src/source-connection.js +451 -0
  191. package/dist/cli/src/source-runtime.js +346 -0
  192. package/dist/cli/src/source-selection-intents.js +75 -0
  193. package/dist/cli/src/source-sets.js +214 -0
  194. package/dist/cli/src/target-models.js +109 -0
  195. package/dist/cli/src/tool-catalog.js +224 -0
  196. package/dist/cli/src/tool-command-contracts.js +1 -0
  197. package/dist/cli/src/tool-command-export.js +245 -0
  198. package/dist/cli/src/tool-connection-contracts.js +1 -0
  199. package/dist/cli/src/tool-connection-reads.js +23 -0
  200. package/dist/cli/src/tool-contributions.js +58 -0
  201. package/dist/cli/src/tool-data.js +116 -0
  202. package/dist/cli/src/tool-datasets.js +176 -0
  203. package/dist/cli/src/tool-group-contracts.js +1 -0
  204. package/dist/cli/src/tool-group-reads.js +26 -0
  205. package/dist/cli/src/tool-group-state.js +147 -0
  206. package/dist/cli/src/tool-groups.js +311 -0
  207. package/dist/cli/src/tool-instances.js +186 -0
  208. package/dist/cli/src/tool-lifecycle-contracts.js +1 -0
  209. package/dist/cli/src/tool-lifecycle-reads.js +30 -0
  210. package/dist/cli/src/tool-lifecycle.js +323 -0
  211. package/dist/cli/src/tool-package-contracts.js +1 -0
  212. package/dist/cli/src/tool-package-reads.js +41 -0
  213. package/dist/cli/src/tool-records.js +53 -0
  214. package/dist/cli/src/tool-runtime.js +96 -0
  215. package/dist/cli/src/tool-state-contracts.js +1 -0
  216. package/dist/cli/src/tool-state-reads.js +31 -0
  217. package/dist/cli/src/tools.js +306 -0
  218. package/dist/cli/src/trial-actions.js +93 -0
  219. package/dist/cli/src/trial-checkpoints.js +133 -0
  220. package/dist/cli/src/trial-checks.js +70 -0
  221. package/dist/cli/src/trial-state.js +197 -0
  222. package/dist/cli/src/vault.js +266 -0
  223. package/dist/cli/src/workspace-registration.js +383 -0
  224. package/dist/cli/src/workspace-team-contracts.js +1 -0
  225. package/dist/cli/src/world-access.js +231 -0
  226. package/dist/cli/src/world-agent.js +4 -0
  227. package/dist/cli/src/world-checkpoint-scopes.js +15 -0
  228. package/dist/cli/src/world-contracts.js +6 -0
  229. package/dist/cli/src/world-creation.js +201 -0
  230. package/dist/cli/src/world-datasets.js +207 -0
  231. package/dist/cli/src/world-inspection.js +257 -0
  232. package/dist/cli/src/world-recipes.js +103 -0
  233. package/dist/cli/src/world-requests.js +241 -0
  234. package/dist/cli/src/world-session-checkpoints.js +196 -0
  235. package/dist/cli/src/world-session-lifecycle.js +207 -0
  236. package/dist/cli/src/world-session-requests.js +215 -0
  237. package/dist/cli/src/world-session-state.js +145 -0
  238. package/dist/cli/src/world-session-voice.js +66 -0
  239. package/dist/cli/src/world-sessions.js +288 -0
  240. package/dist/cli/src/world-stream.js +99 -0
  241. package/dist/cli/src/world-time-report.js +58 -0
  242. package/dist/contracts/agent-change-contracts.js +75 -0
  243. package/dist/contracts/agent-execution-contracts.js +38 -0
  244. package/dist/contracts/application-state-contracts.js +26 -0
  245. package/dist/contracts/assessment-aggregate-contracts.js +15 -0
  246. package/dist/contracts/assessment-contracts.js +74 -0
  247. package/dist/contracts/billing-commerce-contracts.js +107 -0
  248. package/dist/contracts/billing-wallet-contracts.js +43 -0
  249. package/dist/contracts/campaign-lifecycle.js +1 -0
  250. package/dist/contracts/campaign-world-contracts.js +80 -0
  251. package/dist/contracts/catalog-contracts.js +73 -0
  252. package/dist/contracts/ci-access-contracts.js +68 -0
  253. package/dist/contracts/ci-configuration-contracts.js +88 -0
  254. package/dist/contracts/ci-coverage-contracts.js +65 -0
  255. package/dist/contracts/ci-coverage-labels.js +25 -0
  256. package/dist/contracts/ci-delivery-contracts.js +61 -0
  257. package/dist/contracts/ci-result-contracts.js +103 -0
  258. package/dist/contracts/ci-run-contracts.js +51 -0
  259. package/dist/contracts/ci-setup-contracts.js +88 -0
  260. package/dist/contracts/clip-contracts.js +32 -0
  261. package/dist/contracts/comparison-contracts.js +53 -0
  262. package/dist/contracts/composite-contracts.js +34 -0
  263. package/dist/contracts/context-contracts.js +76 -0
  264. package/dist/contracts/continuation-report-contracts.js +1 -0
  265. package/dist/contracts/continuity-requirements.js +8 -0
  266. package/dist/contracts/coordinated-checkpoint-contracts.js +34 -0
  267. package/dist/contracts/dataset-contracts.js +88 -0
  268. package/dist/contracts/dependency-decision-contracts.js +36 -0
  269. package/dist/contracts/dependency-protocol-contracts.js +43 -0
  270. package/dist/contracts/director-execution.js +51 -0
  271. package/dist/contracts/driver-review-contracts.js +99 -0
  272. package/dist/contracts/evidence-contracts.js +65 -0
  273. package/dist/contracts/evidence-coverage-contracts.js +10 -0
  274. package/dist/contracts/export-contracts.js +44 -0
  275. package/dist/contracts/github-directory-contracts.js +96 -0
  276. package/dist/contracts/insight-contracts.js +11 -0
  277. package/dist/contracts/inspection-contracts.js +1 -0
  278. package/dist/contracts/local-claim-contracts.js +39 -0
  279. package/dist/contracts/local-configuration-contracts.js +7 -0
  280. package/dist/contracts/local-evidence-contracts.js +32 -0
  281. package/dist/contracts/local-grant-contracts.js +83 -0
  282. package/dist/contracts/local-input-contracts.js +19 -0
  283. package/dist/contracts/local-runner-contracts.js +42 -0
  284. package/dist/contracts/local-trial-live-contracts.js +16 -0
  285. package/dist/contracts/local-value-source-contracts.js +92 -0
  286. package/dist/contracts/local-world-native-contracts.js +8 -0
  287. package/dist/contracts/mcp-contracts.js +42 -0
  288. package/dist/contracts/media-contracts.js +28 -0
  289. package/dist/contracts/native-check-contracts.js +31 -0
  290. package/dist/contracts/native-identity-contracts.js +52 -0
  291. package/dist/contracts/onboarding-contracts.js +32 -0
  292. package/dist/contracts/performance-contracts.js +85 -0
  293. package/dist/contracts/planning-contracts.js +81 -0
  294. package/dist/contracts/real-operation-contracts.js +30 -0
  295. package/dist/contracts/recommendation-contracts.js +54 -0
  296. package/dist/contracts/report-contracts.js +1 -0
  297. package/dist/contracts/resource-lifecycle-contracts.js +57 -0
  298. package/dist/contracts/revision-contracts.js +12 -0
  299. package/dist/contracts/revision-request-contracts.js +39 -0
  300. package/dist/contracts/saved-record-contracts.js +76 -0
  301. package/dist/contracts/scenario-draft-comparison.js +91 -0
  302. package/dist/contracts/scenario-world-contracts.js +14 -0
  303. package/dist/contracts/service-contracts.js +1 -0
  304. package/dist/contracts/session-command-contracts.js +56 -0
  305. package/dist/contracts/session-contracts.js +65 -0
  306. package/dist/contracts/session-state-contracts.js +67 -0
  307. package/dist/contracts/session-voice-contracts.js +45 -0
  308. package/dist/contracts/source-set-contracts.js +26 -0
  309. package/dist/contracts/source-upload-contracts.js +22 -0
  310. package/dist/contracts/tool-command-contracts.js +57 -0
  311. package/dist/contracts/tool-connection-contracts.js +87 -0
  312. package/dist/contracts/tool-contribution-contracts.js +35 -0
  313. package/dist/contracts/tool-group-contracts.js +53 -0
  314. package/dist/contracts/tool-group-state-contracts.js +63 -0
  315. package/dist/contracts/tool-lifecycle-contracts.js +94 -0
  316. package/dist/contracts/tool-package-contracts.js +91 -0
  317. package/dist/contracts/tool-protocol-contracts.js +71 -0
  318. package/dist/contracts/tool-runtime-billing-contracts.js +28 -0
  319. package/dist/contracts/tool-setup-proposal.js +59 -0
  320. package/dist/contracts/tool-state-contracts.js +79 -0
  321. package/dist/contracts/trial-state-contracts.js +30 -0
  322. package/dist/contracts/typescript/agent-change.js +1 -0
  323. package/dist/contracts/typescript/assessment-aggregate.js +1 -0
  324. package/dist/contracts/typescript/assessment.js +1 -0
  325. package/dist/contracts/typescript/billing-commerce.js +1 -0
  326. package/dist/contracts/typescript/campaigns.js +1 -0
  327. package/dist/contracts/typescript/catalog.js +1 -0
  328. package/dist/contracts/typescript/ci-access.js +1 -0
  329. package/dist/contracts/typescript/ci-configuration.js +1 -0
  330. package/dist/contracts/typescript/ci-delivery.js +1 -0
  331. package/dist/contracts/typescript/ci-result.js +1 -0
  332. package/dist/contracts/typescript/ci-runs.js +1 -0
  333. package/dist/contracts/typescript/clips.js +1 -0
  334. package/dist/contracts/typescript/comparison.js +1 -0
  335. package/dist/contracts/typescript/composite.js +1 -0
  336. package/dist/contracts/typescript/connections.js +1 -0
  337. package/dist/contracts/typescript/context.js +1 -0
  338. package/dist/contracts/typescript/datasets.js +1 -0
  339. package/dist/contracts/typescript/evidence-coverage.js +1 -0
  340. package/dist/contracts/typescript/export.js +1 -0
  341. package/dist/contracts/typescript/github-directory.js +1 -0
  342. package/dist/contracts/typescript/insight.js +1 -0
  343. package/dist/contracts/typescript/inspection.js +1 -0
  344. package/dist/contracts/typescript/intake.js +1 -0
  345. package/dist/contracts/typescript/local-claim.js +1 -0
  346. package/dist/contracts/typescript/local-configuration.js +1 -0
  347. package/dist/contracts/typescript/local-evidence.js +1 -0
  348. package/dist/contracts/typescript/local-grant.js +1 -0
  349. package/dist/contracts/typescript/local-input.js +1 -0
  350. package/dist/contracts/typescript/local-runner.js +1 -0
  351. package/dist/contracts/typescript/local-trial-live.js +1 -0
  352. package/dist/contracts/typescript/local-value-source.js +1 -0
  353. package/dist/contracts/typescript/local-world-native.js +1 -0
  354. package/dist/contracts/typescript/mcp.js +1 -0
  355. package/dist/contracts/typescript/media.js +1 -0
  356. package/dist/contracts/typescript/native-check.js +1 -0
  357. package/dist/contracts/typescript/native-identity.js +1 -0
  358. package/dist/contracts/typescript/onboarding.js +1 -0
  359. package/dist/contracts/typescript/performance.js +1 -0
  360. package/dist/contracts/typescript/planning.js +1 -0
  361. package/dist/contracts/typescript/preparation.js +1 -0
  362. package/dist/contracts/typescript/real-operation.js +1 -0
  363. package/dist/contracts/typescript/recommendation.js +1 -0
  364. package/dist/contracts/typescript/reports.js +1 -0
  365. package/dist/contracts/typescript/resource-lifecycle.js +1 -0
  366. package/dist/contracts/typescript/saved-record.js +1 -0
  367. package/dist/contracts/typescript/scenarios.js +1 -0
  368. package/dist/contracts/typescript/services.js +1 -0
  369. package/dist/contracts/typescript/session-command.js +1 -0
  370. package/dist/contracts/typescript/session-state.js +1 -0
  371. package/dist/contracts/typescript/session-voice.js +1 -0
  372. package/dist/contracts/typescript/session.js +1 -0
  373. package/dist/contracts/typescript/tool-command.js +1 -0
  374. package/dist/contracts/typescript/tool-connection.js +1 -0
  375. package/dist/contracts/typescript/tool-contribution.js +1 -0
  376. package/dist/contracts/typescript/tool-groups.js +1 -0
  377. package/dist/contracts/typescript/tool-lifecycle.js +1 -0
  378. package/dist/contracts/typescript/tool-package.js +1 -0
  379. package/dist/contracts/typescript/tool-state.js +1 -0
  380. package/dist/contracts/typescript/trial-state.js +1 -0
  381. package/dist/contracts/typescript/vault.js +1 -0
  382. package/dist/contracts/typescript/workspace-team.js +1 -0
  383. package/dist/contracts/typescript/world-stream.js +1 -0
  384. package/dist/contracts/workspace-team-contracts.js +55 -0
  385. package/dist/contracts/world-command.js +65 -0
  386. package/dist/contracts/world-lifetime-contracts.js +74 -0
  387. package/dist/contracts/world-recipe-contracts.js +27 -0
  388. package/dist/contracts/world-stream-contracts.js +27 -0
  389. package/dist/package.json +1 -0
  390. package/package.json +16 -16
  391. package/LICENSE.md +0 -23
  392. package/PACKAGING.json +0 -50
@@ -0,0 +1,1354 @@
1
+ /**
2
+ * Native control-plane transport (Plan20 W09, spec 3.5).
3
+ *
4
+ * `httpControlPlane(scope)` is the only HTTP implementation of
5
+ * NativeControlPlane. Released root producers it consumes:
6
+ * - executor presence: the enrolled local runner device's signed presence
7
+ * pulses (openSession/heartbeat/closeSession);
8
+ * - native/campaigns/{campaign}/slots/{slot}/offer and
9
+ * native-executors/{device}/assignments?state=offered: offers to this
10
+ * device (identity, fence, deadline; no commands, bindings or budgets);
11
+ * - native-executors/{device}/assignments/{id}/control: signed
12
+ * firedrill.native-control.v1 actions with a per-assignment sequence
13
+ * (accept, reject, lease renewal);
14
+ * - .../artifacts and .../artifacts/commit: signed evidence declarations,
15
+ * a write-once storage PUT, then a signed commit root inspects;
16
+ * - .../observations: signed observation-batch.v1 documents, acknowledged
17
+ * by `acknowledged_sequence`;
18
+ * - .../actions and .../actions/{id}/control: offered participant-message
19
+ * target actions, a signed claim that returns the frozen dispatch once
20
+ * (a replayed claim is `recovered` and never invoked again), a signed
21
+ * complete naming the acknowledged v2 reply event, and a signed abandon
22
+ * withdrawing this client's claim of an input that never reached the target.
23
+ * Every signed request is re-sent byte-identical on retry (same request key,
24
+ * deterministic Ed25519 signature), so root returns its retained original.
25
+ *
26
+ * - .../artifacts (readiness): the signed execution-readiness.v1 document,
27
+ * committed as the driver.started record the drill's single driver.started
28
+ * observation references; root's controller then admits the drill on its own
29
+ * (recordReadiness);
30
+ * - .../stop: a signed native-execution-stop.v1 request once the drill stops;
31
+ * root freezes dispatch, withdraws only never-claimed offers and returns the
32
+ * exact last target action, the cutoff the close names (stopExecution);
33
+ * - .../close: the signed execution-close.v1 document, committed as the
34
+ * driver.<outcome> evidence record (recordClose); the drill's final outcome
35
+ * observation references that record, and once root acknowledged it the
36
+ * close is submitted by artifact id (submitClose);
37
+ * - .../dependency-access: a signed native-dependency-access.v1 request for one
38
+ * simulated dependency's short-lived gateway token (dependencyAccess);
39
+ * - .../cleanup: after root fenced an execution (lease expired or revoked), its
40
+ * frozen cleanup challenge (readCleanup) and a signed cleanup-only
41
+ * execution-cleanup.v1 report of how each service ended (submitCleanup);
42
+ * - native-executors/{device}/campaigns/{campaign}/run: the campaign's local run
43
+ * on this device, read (readRun) and advanced by signed
44
+ * local-run-lifecycle.v1 events (submitRun: register, observe, close);
45
+ * - native/devices/{device}/campaigns/{campaign}/stream: the selected
46
+ * campaign's companion channel (companion-channel.ts, `wakes`), whose
47
+ * invalidation hints wake the offer loop and the drills instead of timers.
48
+ * It never replaces a signed request or its receipt.
49
+ *
50
+ * Every operation is wired to a released root producer; nothing here is
51
+ * pending. Malformed local input is refused before anything is sent:
52
+ * - a step whose id, kind or input digest is malformed (`native_target_action_invalid`);
53
+ * - a completion without its own action id and acknowledged reply event
54
+ * (`native_target_completion_invalid`).
55
+ * Accepting an offer does not start the target: root's controller admits
56
+ * capacity and bindings before any action, and cleanup, scoring and settlement
57
+ * after a close are root's. Executor routes use the server device id from the
58
+ * runner receipt (NativeExecutor.device_id). A companion without an enrolled
59
+ * runner device is refused with `runner_required`, and a receipt without a
60
+ * device id with `runner_receipt_invalid`. No route is guessed and no success
61
+ * is fabricated.
62
+ *
63
+ * This module also owns the transport-level helpers the companion and drill
64
+ * runner share: transient-error classification, capped exponential backoff with
65
+ * jitter, abortable sleep, bounded retry and the connection gate that pauses new
66
+ * steps while disconnected.
67
+ */
68
+ import { createHash, randomUUID } from 'node:crypto';
69
+ import { open, rename, rm } from 'node:fs/promises';
70
+ import { join } from 'node:path';
71
+ import { privateDirectory } from '../credentials.js';
72
+ import { PNG_MAX_BYTES, isMetadataFreePng } from './png.js';
73
+ import { api } from '../auth.js';
74
+ import { CliError } from '../http.js';
75
+ import { companionChannel } from './companion-channel.js';
76
+ import { ENDING_REASONS, EVENT_SIGNATURE_HEADER, EVIDENCE_RECORD_MAX_BYTES, LOCAL_RUN_MAX_SERVICES, NATIVE_ERRORS, NATIVE_ID, READINESS_MAX_RESETS, READINESS_MAX_SERVICES, SHA256_HEX, STOP_REASONS, SecretValue, TARGET_ACTION_KINDS, nativeError, } from './contracts.js';
77
+ /** Root's size limit for the signed close record. */
78
+ const CLOSE_MAX_BYTES = 65_536;
79
+ /**
80
+ * One target session per drill attempt: every message and task of an attempt
81
+ * goes to the same session, and no two attempts share one. A fixed key would let
82
+ * a target that keeps conversation state by session continue an earlier drill's
83
+ * conversation, so a `fresh-session` reset would be claimed without happening.
84
+ * The key is a digest of the attempt id, so no service id reaches the target,
85
+ * and it is stable when a restarted runner resumes the same attempt.
86
+ */
87
+ function participantSession(assignment) {
88
+ return `participant-${createHash('sha256').update(`firedrill.participant-session.v1\n${assignment.attempt_id}`).digest('hex').slice(0, 32)}`;
89
+ }
90
+ /** Offered-action pages read per poll (root keeps at most one live action per assignment). */
91
+ const ACTION_PAGES = 5;
92
+ /** Offers listed per claim (oldest unexpired first after filtering). */
93
+ const OFFER_PAGE = 20;
94
+ /** Drill deadline this client requests for a slot offer (root allows at most 24 hours). */
95
+ const OFFER_DEADLINE_MS = 2 * 60 * 60 * 1000;
96
+ /** Root refusals that mean "no offer for this device right now", not a failure. */
97
+ const NO_OFFER_CODES = new Set(['native_campaign_offer_unavailable', 'native_campaign_parallel_limit', 'native_executor_parallel_limit',
98
+ 'native_device_unavailable', 'native_local_run_unavailable']);
99
+ const LOCAL_RUN_STATES = new Set(['active', 'uncertain', 'closed']);
100
+ /** Root accepts at most this many sequenced cleanup reports per frozen execution. */
101
+ const CLEANUP_MAX_REPORTS = 1024;
102
+ const LOCAL_RUN_SERVICE_STATES = new Set(['planned', 'running', 'reaped', 'not_started', 'unknown']);
103
+ /** Server codes that mean this attempt may no longer act, or this executor lost its authority. */
104
+ const FENCE_CODES = {
105
+ native_lease_inactive: NATIVE_ERRORS.lease_expired,
106
+ native_fence_stale: NATIVE_ERRORS.stale_generation,
107
+ };
108
+ const REVOKED_CODES = new Set(['native_session_required', 'native_session_inactive', 'native_device_inactive',
109
+ 'write_access_required', 'runner_access_removed', 'runner_scope_changed', 'runner_revoked']);
110
+ /** Translate root refusals into the native fence and revocation codes the runner classifies. */
111
+ function translated(error) {
112
+ if (!(error instanceof CliError))
113
+ return error;
114
+ const fence = FENCE_CODES[error.code];
115
+ if (fence)
116
+ return new CliError(fence, error.message, error.exitCode, error.httpStatus);
117
+ if (REVOKED_CODES.has(error.code))
118
+ return new CliError(NATIVE_ERRORS.revoked, error.message, error.exitCode, error.httpStatus);
119
+ return error;
120
+ }
121
+ /** Root's signed-payload canonical form: sorted keys, compact, ASCII-escaped (Python ensure_ascii). */
122
+ function rootCanonical(value) {
123
+ if (Array.isArray(value))
124
+ return `[${value.map(rootCanonical).join(',')}]`;
125
+ if (value && typeof value === 'object') {
126
+ const object = value;
127
+ return `{${Object.keys(object).sort().map((key) => `${rootCanonical(key)}:${rootCanonical(object[key])}`).join(',')}}`;
128
+ }
129
+ const encoded = JSON.stringify(value);
130
+ if (encoded === undefined)
131
+ throw new CliError('native_request_invalid', 'A signed request held an unsupported value. Nothing was submitted.');
132
+ return encoded.replace(/[\u007f-\uffff]/g, (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
133
+ }
134
+ const digestOf = (value) => createHash('sha256').update(rootCanonical(value), 'ascii').digest('hex');
135
+ /** Root's cap on a claim's `dispatch_canonical` (supplementary characters escape to 12 bytes each). */
136
+ const MAX_DISPATCH_CANONICAL = 262144;
137
+ /**
138
+ * The dispatch a fresh claim froze, read from root's exact canonical text: printable ASCII only
139
+ * (Python ensure_ascii), within the cap, and hashing byte-for-byte to the action's input digest.
140
+ * Only then is it parsed; null when any of that fails.
141
+ */
142
+ function frozenDispatch(text, inputDigest) {
143
+ if (typeof text !== 'string' || !text || text.length > MAX_DISPATCH_CANONICAL || !/^[\x20-\x7e]+$/.test(text))
144
+ return null;
145
+ if (createHash('sha256').update(text, 'ascii').digest('hex') !== inputDigest)
146
+ return null;
147
+ try {
148
+ return record(JSON.parse(text));
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ }
154
+ const isUuid = (value) => typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value);
155
+ /** Readiness and descriptor aliases (stricter than the close's service pattern). */
156
+ const READINESS_ALIAS = /^[a-z][a-z0-9_-]{0,63}$/;
157
+ const HANDSHAKE_PROBES = new Set(['jsonl_handshake', 'function_import', 'browser_document', 'signed_event_handshake']);
158
+ /** One execution-readiness.v1 service entry: a run reference only for a run-owned service. */
159
+ function readinessServiceValid(item) {
160
+ const probe = item.probe;
161
+ const probeValid = probe.kind === 'http' ? Number.isSafeInteger(probe.status_code) && probe.status_code >= 200 && probe.status_code <= 299
162
+ : probe.kind === 'process' ? probe.observation === 'running'
163
+ : HANDSHAKE_PROBES.has(probe.kind) && SHA256_HEX.test(probe.response_sha256);
164
+ return READINESS_ALIAS.test(item.service) && isUuid(item.launch_id) && probeValid
165
+ && (item.ownership === 'owned_run' ? isUuid(item.run_id) : (item.ownership === 'owned' || item.ownership === 'attached') && item.run_id === null);
166
+ }
167
+ /** One reset evidence entry: a hook digest exactly when the reset is an approved hook. */
168
+ function readinessResetValid(item) {
169
+ return READINESS_ALIAS.test(item.alias) && ['fresh-session', 'fresh-process', 'approved-hook'].includes(item.reset)
170
+ && (item.reset === 'approved-hook' ? typeof item.hook_sha256 === 'string' && SHA256_HEX.test(item.hook_sha256) : item.hook_sha256 === null)
171
+ && isUuid(item.evidence_artifact_id) && SHA256_HEX.test(item.evidence_sha256);
172
+ }
173
+ const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : null;
174
+ function receiptInvalid(what) {
175
+ return new CliError('native_receipt_invalid', `The service response for ${what} did not match the request. Nothing further was sent for it.`);
176
+ }
177
+ const time = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value));
178
+ const count = (value, min) => typeof value === 'number' && Number.isSafeInteger(value) && value >= min;
179
+ /** The local step kind each target-action kind runs as. */
180
+ const STEP_KIND = {
181
+ participant_message: 'message', scenario_message: 'message', task: 'task', event: 'event',
182
+ };
183
+ /** Root's target-visible event path: relative to the locally approved receiver prefix. */
184
+ const EVENT_PATH = /^\/[A-Za-z0-9_/-]{0,255}$/;
185
+ /** A listed step's payload before its claim returns the input (listing carries none). */
186
+ function placeholderPayload(kind, actionId, session) {
187
+ if (kind === 'task')
188
+ return { kind: 'task', input: null, session_key: session };
189
+ if (kind === 'event') {
190
+ return { kind: 'event', delivery_id: actionId, method: 'POST', path: '/', headers: {}, body_base64: '', signature_header: null };
191
+ }
192
+ return { kind: 'message', text: '', session_key: session };
193
+ }
194
+ /**
195
+ * The input a claimed dispatch carries, or null when it does not match its
196
+ * kind: v1 for a participant message (unchanged), v2 for a scripted message, a
197
+ * task or a signed event. An event's delivery id is its action id, and its
198
+ * signature header is root's; the signing key is never in a dispatch.
199
+ */
200
+ function dispatchedPayload(dispatch, kind, session) {
201
+ if (kind === 'participant_message') {
202
+ if (dispatch.schema !== 'firedrill.native-target-action.v1' || typeof dispatch.text !== 'string' || !isUuid(dispatch.participant_step_id))
203
+ return null;
204
+ return { kind: 'message', text: dispatch.text, session_key: session };
205
+ }
206
+ if (dispatch.schema !== 'firedrill.native-target-action.v2' || dispatch.participant_step_id !== null
207
+ || (kind === 'scenario_message' ? !isUuid(dispatch.scenario_event_id) : dispatch.scenario_event_id !== null))
208
+ return null;
209
+ const payload = record(dispatch.payload);
210
+ if (!payload)
211
+ return null;
212
+ const keys = Object.keys(payload).sort().join(',');
213
+ if (kind === 'scenario_message') {
214
+ return keys === 'kind,text' && payload.kind === 'message' && typeof payload.text === 'string'
215
+ ? { kind: 'message', text: payload.text, session_key: session } : null;
216
+ }
217
+ if (kind === 'task')
218
+ return keys === 'input,kind' && payload.kind === 'task' ? { kind: 'task', input: payload.input, session_key: session } : null;
219
+ const headers = record(payload.headers);
220
+ const method = payload.method;
221
+ if (keys !== 'body_base64,delivery_id,headers,kind,method,path,signature_header' || payload.kind !== 'event'
222
+ || (method !== 'POST' && method !== 'PUT' && method !== 'PATCH')
223
+ || typeof payload.path !== 'string' || !EVENT_PATH.test(payload.path) || payload.path.includes('//')
224
+ || !headers || !Object.keys(headers).every((name) => name === 'content-type')
225
+ || (headers['content-type'] !== undefined && headers['content-type'] !== 'application/json')
226
+ || typeof payload.body_base64 !== 'string' || payload.signature_header !== EVENT_SIGNATURE_HEADER
227
+ || payload.delivery_id !== dispatch.action_id)
228
+ return null;
229
+ return { kind: 'event', delivery_id: payload.delivery_id, method, path: payload.path,
230
+ headers: headers, body_base64: payload.body_base64, signature_header: EVENT_SIGNATURE_HEADER };
231
+ }
232
+ const SCHEDULE_KEYS = 'completed,completed_target_turns,due_remaining,future_conditions,next_after_turn,scheduled';
233
+ const SCHEDULE_KEYS_V3 = 'active_elapsed_ms,completed,completed_target_turns,due_remaining,future_conditions,future_timers,'
234
+ + 'next_after_active_ms,next_after_turn,scheduled';
235
+ /**
236
+ * Root's delivery status on a target-actions page. A page without it (a root
237
+ * that predates the status) says nothing has ended. `done` always names its
238
+ * ending reason, and a reason never comes without `done`. The schedule summary
239
+ * is optional (root sends it once nothing is in flight) and exact when present.
240
+ */
241
+ function deliveryStatus(raw) {
242
+ const reconciliation = raw.reconciliation_required ?? null;
243
+ if (reconciliation !== null && (reconciliation !== 'participant_outcome_unknown' || raw.done !== false)) {
244
+ throw receiptInvalid('the target actions');
245
+ }
246
+ if (raw.done === undefined && raw.cancelled === undefined && raw.ending_reason === undefined && raw.schedule === undefined) {
247
+ return { done: false, cancelled: false, ending_reason: null, schedule: null };
248
+ }
249
+ const reason = raw.ending_reason;
250
+ if (typeof raw.done !== 'boolean' || typeof raw.cancelled !== 'boolean'
251
+ || (reason !== null && !ENDING_REASONS.includes(reason)) || raw.done !== (reason !== null)) {
252
+ throw receiptInvalid('the target actions');
253
+ }
254
+ let schedule = null;
255
+ if (raw.schedule !== undefined) {
256
+ const value = record(raw.schedule);
257
+ const keys = value ? Object.keys(value).sort().join(',') : '';
258
+ // Only a v3 scenario adds its active schedule time; then all three timer fields are present.
259
+ const timed = keys === SCHEDULE_KEYS_V3;
260
+ if (!value || (keys !== SCHEDULE_KEYS && !timed)
261
+ || !count(value.scheduled, 0) || !count(value.completed, 0) || !count(value.due_remaining, 0)
262
+ || !count(value.future_conditions, 0) || !count(value.completed_target_turns, 0)
263
+ || (value.next_after_turn !== null && !count(value.next_after_turn, 1))
264
+ || (timed && (!count(value.active_elapsed_ms, 0) || !count(value.future_timers, 0)
265
+ || (value.next_after_active_ms === null) !== (value.future_timers === 0)
266
+ || (value.next_after_active_ms !== null && !(count(value.next_after_active_ms, 0)
267
+ && value.next_after_active_ms > value.active_elapsed_ms))))) {
268
+ throw receiptInvalid('the target actions');
269
+ }
270
+ schedule = value;
271
+ }
272
+ return { done: raw.done, cancelled: raw.cancelled, ending_reason: reason, schedule,
273
+ reconciliation_required: reconciliation };
274
+ }
275
+ /** Root's local-run service snapshot: unique aliases and launch ids, an exit code exactly when reaped. */
276
+ function runServices(value) {
277
+ if (!Array.isArray(value) || value.length < 1 || value.length > LOCAL_RUN_MAX_SERVICES)
278
+ return null;
279
+ const services = [];
280
+ for (const entry of value) {
281
+ const item = record(entry);
282
+ if (!item || Object.keys(item).sort().join(',') !== 'exit_code,launch_id,service,state' || typeof item.service !== 'string'
283
+ || !READINESS_ALIAS.test(item.service) || !isUuid(item.launch_id) || typeof item.state !== 'string' || !LOCAL_RUN_SERVICE_STATES.has(item.state)
284
+ || (item.state === 'reaped' ? !Number.isSafeInteger(item.exit_code) || item.exit_code < -255 || item.exit_code > 255
285
+ : item.exit_code !== null))
286
+ return null;
287
+ services.push({ service: item.service, launch_id: item.launch_id, state: item.state, exit_code: item.exit_code });
288
+ }
289
+ if (new Set(services.map((item) => item.service)).size !== services.length
290
+ || new Set(services.map((item) => item.launch_id)).size !== services.length)
291
+ return null;
292
+ return services;
293
+ }
294
+ /** Root AssignmentReceipt for this device, validated field by field. */
295
+ function offerRow(value, deviceId, campaignId, slotId) {
296
+ const raw = record(value);
297
+ if (!raw || !isUuid(raw.id) || !isUuid(raw.attempt_id) || raw.device_id !== deviceId || !isUuid(raw.descriptor_id)
298
+ || !isUuid(raw.world_revision_id) || typeof raw.approved_plan_digest !== 'string' || !SHA256_HEX.test(raw.approved_plan_digest)
299
+ || typeof raw.state !== 'string' || !/^[a-z_]{1,24}$/.test(raw.state) || !count(raw.lease_epoch, 1) || !count(raw.state_generation, 1)
300
+ || !time(raw.offer_expires_at) || !time(raw.lease_expires_at) || !time(raw.deadline_at) || !count(raw.control_sequence, 0)
301
+ || !count(raw.observation_sequence, 0) || !count(raw.observation_elapsed_ms, 0) || (raw.revoked_at !== null && !time(raw.revoked_at))
302
+ || typeof raw.descriptor_digest !== 'string' || !SHA256_HEX.test(raw.descriptor_digest)
303
+ // Root names the offer's campaign (null for a controller offer); a root that predates it sends none.
304
+ || (raw.campaign_id !== undefined && raw.campaign_id !== null && !isUuid(raw.campaign_id))
305
+ || (campaignId !== null && raw.campaign_id !== undefined && raw.campaign_id !== campaignId)) {
306
+ throw receiptInvalid('the drill offer');
307
+ }
308
+ return { assignment_id: raw.id, attempt_id: raw.attempt_id, device_id: deviceId, descriptor_id: raw.descriptor_id,
309
+ world_revision_id: raw.world_revision_id, descriptor_sha256: raw.descriptor_digest,
310
+ approved_plan_digest: raw.approved_plan_digest, state: raw.state, lease_epoch: raw.lease_epoch, state_generation: raw.state_generation,
311
+ offer_expires_at: raw.offer_expires_at, lease_expires_at: raw.lease_expires_at, deadline_at: raw.deadline_at,
312
+ control_sequence: raw.control_sequence, observation_sequence: raw.observation_sequence,
313
+ observation_elapsed_ms: raw.observation_elapsed_ms, campaign_id: campaignId ?? (typeof raw.campaign_id === 'string' ? raw.campaign_id : null),
314
+ slot_id: slotId };
315
+ }
316
+ /** Storage write: exact signed PUT, no redirects or ambient credentials. 412 means the write-once object already exists. */
317
+ async function putEvidence(upload, bytes, signal) {
318
+ let destination;
319
+ const headers = record(upload.headers);
320
+ try {
321
+ destination = new URL(String(upload.upload_url));
322
+ if (upload.method !== 'PUT' || destination.origin !== 'https://storage.googleapis.com' || destination.username
323
+ || destination.password || destination.hash || !headers
324
+ || Object.values(headers).some((value) => typeof value !== 'string' || !/^[\x20-\x7e]{1,256}$/.test(value))
325
+ || headers['Content-Length'] !== undefined && headers['Content-Length'] !== String(bytes.byteLength))
326
+ throw new Error();
327
+ }
328
+ catch {
329
+ throw receiptInvalid('the evidence upload');
330
+ }
331
+ let response;
332
+ try {
333
+ response = await fetch(destination, { method: 'PUT', headers: headers, body: new Uint8Array(bytes).buffer,
334
+ redirect: 'error', credentials: 'omit', signal: AbortSignal.any([signal, AbortSignal.timeout(60_000)]) });
335
+ await response.body?.cancel();
336
+ }
337
+ catch (error) {
338
+ if (signal.aborted)
339
+ throw error;
340
+ throw new CliError('connection_uncertain', 'The evidence upload was not confirmed. It is retried with the same record.');
341
+ }
342
+ if ([200, 201, 412].includes(response.status))
343
+ return;
344
+ // An expired or refused signed URL is renewed by declaring the same record again.
345
+ throw new CliError('connection_uncertain', 'The evidence upload was not accepted. It is retried with the same record.', 1, response.status >= 500 || response.status === 429 || response.status === 408 ? response.status : 503);
346
+ }
347
+ export function httpControlPlane(scope) {
348
+ if (!scope || typeof scope.workspace !== 'string' || !scope.workspace) {
349
+ throw new CliError('workspace_required', 'Select a workspace before starting the native companion. Nothing was started.', 2);
350
+ }
351
+ const options = (signal) => ({ actor: scope.actor, workspace: scope.workspace, signal });
352
+ const closeJournalPath = async (assignment) => {
353
+ const identity = JSON.stringify([scope.workspace, scope.actor ?? null, scope.executor?.device_key,
354
+ assignment.assignment_id, assignment.attempt_id]);
355
+ return join(await privateDirectory(), `native-close-${createHash('sha256').update(identity).digest('hex')}.json`);
356
+ };
357
+ const retainClose = async (assignment, state) => {
358
+ const path = await closeJournalPath(assignment);
359
+ const temporary = `${path}.${randomUUID()}.tmp`;
360
+ const file = await open(temporary, 'wx', 0o600);
361
+ try {
362
+ // Recovery identity only: no output, process evidence, signed payload,
363
+ // token or capability is stored locally. The canonical artifact is cloud-owned.
364
+ await file.writeFile(JSON.stringify({ schema: 'firedrill.native-close-intent.v1',
365
+ workspace_id: scope.workspace, actor_id: scope.actor ?? null,
366
+ device_key: scope.executor?.device_key, device_id: scope.executor?.device_id,
367
+ campaign_id: scope.campaign?.id ?? null, ...fenceOf(assignment),
368
+ close_artifact_id: state.artifact_id, request_key: state.request_key,
369
+ observation_sequence: state.observation_sequence, driver_outcome: state.driver_outcome,
370
+ sha256: state.sha256 }));
371
+ await file.sync();
372
+ }
373
+ finally {
374
+ await file.close();
375
+ }
376
+ await rename(temporary, path);
377
+ };
378
+ const controls = new Map();
379
+ /** One claim request key per target action (keyed by attempt and action), so a retried claim replays instead of claiming anew. */
380
+ const claimKeys = new Map();
381
+ /** One withdrawal request key per claimed action (keyed by attempt and action), so a retried withdrawal replays the original. */
382
+ const abandonKeys = new Map();
383
+ /** The deadline first requested per slot: root refuses a retried offer with a different one. */
384
+ const offerDeadlines = new Map();
385
+ /** A slot whose offer was sent but never answered: asked again (same device and deadline) before any other slot. */
386
+ let unansweredSlot = null;
387
+ /** The selected campaign was confirmed to run the approved descriptor (checked before any slot is taken). */
388
+ let campaignConfirmed = false;
389
+ /** Offers this companion rejected, or whose accept root refused; not returned again while root still lists them (bounded). */
390
+ const rejected = new Set();
391
+ const remember = (assignmentId) => {
392
+ rejected.add(assignmentId);
393
+ if (rejected.size > OFFER_PAGE * 50)
394
+ rejected.delete(rejected.values().next().value);
395
+ };
396
+ /** The signed close per attempt, so a retried close re-sends the same document and record. */
397
+ const closes = new Map();
398
+ /** The signed stop per attempt, so a retried stop re-sends the same request key and document. */
399
+ const stops = new Map();
400
+ /** The signed cleanup report per challenge sequence, so a retry re-sends the same request key and document. */
401
+ const cleanups = new Map();
402
+ /** The signed readiness per attempt (for one observation sequence), so a retry re-sends the same record. */
403
+ const readinessRecords = new Map();
404
+ /**
405
+ * Once root accepted an attempt's close or confirmed its cleanup report, no retry can need that attempt's
406
+ * keys or signed records, so they are dropped and a long-lived companion does not keep every drill's bytes.
407
+ */
408
+ const actionKey = (assignment, actionId) => `${assignment.assignment_id}\0${assignment.attempt_id}\0${actionId}`;
409
+ const forget = (assignment) => {
410
+ const key = `${assignment.assignment_id}\0${assignment.attempt_id}`;
411
+ closes.delete(key);
412
+ stops.delete(key);
413
+ readinessRecords.delete(key);
414
+ for (const map of [cleanups, claimKeys, abandonKeys]) {
415
+ for (const entry of map.keys())
416
+ if (entry.startsWith(`${key}\0`))
417
+ map.delete(entry);
418
+ }
419
+ };
420
+ const executor = (operation) => {
421
+ const value = scope.executor;
422
+ if (!value)
423
+ throw new CliError('runner_required', `The ${operation} operation was not sent: no enrolled runner device executes for this companion. Enroll one with firedrill runner enroll. Nothing was submitted.`);
424
+ return value;
425
+ };
426
+ /** The server device id from the runner receipt, which executor routes and offers are keyed on. */
427
+ const deviceId = (operation) => {
428
+ const device = executor(operation).device_id;
429
+ if (!isUuid(device)) {
430
+ throw new CliError('runner_receipt_invalid', `The ${operation} operation was not sent: the runner receipt has no device id. Nothing was submitted.`);
431
+ }
432
+ return device;
433
+ };
434
+ /** Executor route for one assignment. */
435
+ const assignmentPath = (operation, assignmentId, suffix = '') => {
436
+ const device = deviceId(operation);
437
+ if (!isUuid(assignmentId))
438
+ throw new CliError(NATIVE_ERRORS.assignment_invalid, 'The assignment id is malformed. Nothing was submitted.');
439
+ return `/api/workspaces/${scope.workspace}/native-executors/${device}/assignments/${assignmentId}${suffix}`;
440
+ };
441
+ /** Remember root's control cursor for an offer, once; a known cursor (possibly mid-control) is kept. */
442
+ const track = (offer) => {
443
+ if (!controls.has(offer.assignment_id))
444
+ controls.set(offer.assignment_id, { sequence: offer.control_sequence,
445
+ approved_plan_digest: offer.approved_plan_digest, chain: Promise.resolve(), unconfirmed: null });
446
+ return offer;
447
+ };
448
+ /** POST one signed document; retried byte-identical on transient failures by the caller. */
449
+ const signedPost = async (path, payload, signal, operation) => {
450
+ const document = await executor(operation).sign(payload, signal);
451
+ try {
452
+ return await api(path, { method: 'POST', body: document, ...options(signal) });
453
+ }
454
+ catch (error) {
455
+ throw translated(error);
456
+ }
457
+ };
458
+ const fenceOf = (assignment) => ({
459
+ assignment_id: assignment.assignment_id, attempt_id: assignment.attempt_id,
460
+ lease_epoch: assignment.lease_epoch, state_generation: assignment.state_generation,
461
+ });
462
+ /** One control action, serialized per assignment so sequences never race. */
463
+ const control = (assignment, action, signal) => {
464
+ const state = controls.get(assignment.assignment_id);
465
+ if (!state)
466
+ return Promise.reject(new CliError(NATIVE_ERRORS.assignment_invalid, `The ${action} control was not sent: this companion never read that assignment's offer, so no control cursor is known. Nothing was submitted.`));
467
+ /** Send one control document (retried byte-identical) and check root's receipt for it. */
468
+ const send = async (payload) => {
469
+ state.unconfirmed = payload;
470
+ const path = assignmentPath('control', assignment.assignment_id, '/control');
471
+ let raw;
472
+ try {
473
+ raw = record(await retryTransient(() => signedPost(path, payload, signal, 'control'), { signal, attempts: 4 }));
474
+ }
475
+ catch (error) {
476
+ // Only an answered refusal is final; an unanswered or cancelled request may have landed.
477
+ if (!isTransient(error) && !isAbort(error, signal))
478
+ state.unconfirmed = null;
479
+ throw error;
480
+ }
481
+ state.unconfirmed = null;
482
+ if (!raw || raw.id !== assignment.assignment_id || raw.attempt_id !== assignment.attempt_id
483
+ || raw.control_sequence !== payload.sequence || typeof raw.lease_expires_at !== 'string'
484
+ || !Number.isFinite(Date.parse(raw.lease_expires_at)))
485
+ throw receiptInvalid('the assignment control');
486
+ state.sequence = payload.sequence;
487
+ // A cancel revokes this attempt and moves the lease epoch on by exactly one; nothing else changes it.
488
+ if (payload.action === 'cancel') {
489
+ if (raw.lease_epoch !== assignment.lease_epoch + 1 || raw.state_generation !== assignment.state_generation
490
+ || typeof raw.revoked_at !== 'string' || !Number.isFinite(Date.parse(raw.revoked_at)))
491
+ throw receiptInvalid('the assignment cancellation');
492
+ return raw;
493
+ }
494
+ if (raw.lease_epoch !== assignment.lease_epoch || raw.state_generation !== assignment.state_generation) {
495
+ throw nativeError('stale_generation', 'The assignment moved to a newer lease or state generation. This attempt stops before its next step.');
496
+ }
497
+ return raw;
498
+ };
499
+ const run = state.chain.catch(() => undefined).then(async () => {
500
+ // A heartbeat whose answer was lost is replayed byte-identical first (root answers a repeated request once),
501
+ // so liveness alone never blocks a withdrawal; its refusal is final. Any other unconfirmed control still stops here.
502
+ if (state.unconfirmed !== null && state.unconfirmed.action === 'heartbeat' && action !== 'heartbeat')
503
+ await send(state.unconfirmed);
504
+ const prior = state.unconfirmed;
505
+ const payload = prior !== null && prior.action === action ? prior : { schema: 'firedrill.native-control.v1',
506
+ ...fenceOf(assignment), request_key: randomUUID(), sequence: state.sequence + 1, action, approved_plan_digest: state.approved_plan_digest };
507
+ if (prior !== null && prior !== payload) {
508
+ throw nativeError('stale_generation', 'An earlier execution control is still unconfirmed. This attempt stops before its next step.');
509
+ }
510
+ return send(payload);
511
+ });
512
+ state.chain = run;
513
+ return run;
514
+ };
515
+ /** The selected campaign's detail, confirmed to run the approved descriptor. */
516
+ const campaignDetail = async (campaignId, signal) => {
517
+ if (!scope.campaign || campaignId !== scope.campaign.id)
518
+ throw new CliError('arguments_invalid', 'Only the selected campaign is read. Nothing was started.', 2);
519
+ if (!isUuid(campaignId))
520
+ throw new CliError('arguments_invalid', 'Use the campaign id exactly as Firedrill printed it. Nothing was started.', 2);
521
+ let detail;
522
+ try {
523
+ detail = await api(`/api/workspaces/${scope.workspace}/native/campaigns/${campaignId}`, options(signal));
524
+ }
525
+ catch (error) {
526
+ throw translated(error);
527
+ }
528
+ const campaign = record(detail);
529
+ if (!campaign || campaign.campaign_id !== campaignId)
530
+ throw receiptInvalid('the campaign');
531
+ if (typeof campaign.descriptor_digest !== 'string' || !SHA256_HEX.test(campaign.descriptor_digest))
532
+ throw receiptInvalid('the campaign');
533
+ if (campaign.descriptor_digest !== scope.campaign.descriptor_sha256) {
534
+ throw nativeError('plan_changed', 'No drill was requested: this campaign runs a different agent descriptor than the approved plan. No drill slot was taken.', 2);
535
+ }
536
+ return campaign;
537
+ };
538
+ /** The campaign's local-run route for this device (root `native_local_run_read` / `native_local_run_lifecycle`). */
539
+ const runPath = (operation, campaignId) => {
540
+ if (!scope.campaign || campaignId !== scope.campaign.id || !isUuid(campaignId)) {
541
+ throw new CliError('arguments_invalid', 'A local run belongs to the selected campaign only. Nothing was submitted.', 2);
542
+ }
543
+ return `/api/workspaces/${scope.workspace}/native-executors/${deviceId(operation)}/campaigns/${campaignId}/run`;
544
+ };
545
+ /** Root's run receipt, checked against this run, campaign and device. */
546
+ const runReceipt = (raw, campaignId, operation, runId) => {
547
+ const value = record(raw);
548
+ if (!value || !isUuid(value.run_id) || (runId !== null && value.run_id !== runId) || value.campaign_id !== campaignId
549
+ || value.device_id !== deviceId(operation) || !count(value.sequence, 1) || typeof value.state !== 'string'
550
+ || !LOCAL_RUN_STATES.has(value.state) || (value.closed_at !== null && (typeof value.closed_at !== 'string' || !Number.isFinite(Date.parse(value.closed_at))))
551
+ || (value.state === 'closed') !== (value.closed_at !== null) || value.process_provenance !== 'customer_device'
552
+ || value.cloud_cleanup_complete !== false || typeof value.descriptor_digest !== 'string' || !SHA256_HEX.test(value.descriptor_digest)) {
553
+ throw receiptInvalid('the local run');
554
+ }
555
+ const services = runServices(value.services);
556
+ if (!services)
557
+ throw receiptInvalid('the local run');
558
+ return { run_id: value.run_id, sequence: value.sequence, state: value.state,
559
+ closed_at: value.closed_at, descriptor_digest: value.descriptor_digest, services };
560
+ };
561
+ const plane = {
562
+ // One shared channel for the selected campaign (root keeps one per device); a companion needs a campaign.
563
+ ...(scope.campaign ? { wakes: companionChannel({ workspace: scope.workspace, actor: scope.actor, campaign: scope.campaign.id,
564
+ device: () => deviceId('companionChannel') }) } : {}),
565
+ // Presence of the enrolled runner device stands in for the companion session.
566
+ // Capabilities stay local: root has no capability route for native executors.
567
+ async openSession(_capabilities, signal) {
568
+ const device = executor('openSession');
569
+ const receipt = await device.pulse('idle', signal).catch((error) => { throw translated(error); });
570
+ if (['revoked', 'expired', 'access_removed', 'not_retained'].includes(receipt.state)) {
571
+ throw new CliError(NATIVE_ERRORS.revoked, 'This runner device is no longer authorized. Nothing was started.');
572
+ }
573
+ return { session_id: device.device_key, heartbeat_ms: 20_000 };
574
+ },
575
+ async heartbeat(_sessionId, signal) {
576
+ const receipt = await executor('heartbeat').pulse('idle', signal).catch((error) => { throw translated(error); });
577
+ return { revoked: ['revoked', 'expired', 'access_removed', 'not_retained'].includes(receipt.state) };
578
+ },
579
+ async campaignPolicy(campaignId, signal) {
580
+ const campaign = await campaignDetail(campaignId, signal);
581
+ if (!count(campaign.max_parallel, 1) || typeof campaign.state !== 'string' || !campaign.state)
582
+ throw receiptInvalid('the campaign');
583
+ return { max_parallel: campaign.max_parallel, state: campaign.state };
584
+ },
585
+ async readRun(campaignId, signal) {
586
+ let raw;
587
+ try {
588
+ raw = await api(runPath('readRun', campaignId), options(signal));
589
+ }
590
+ catch (error) {
591
+ // Only root's explicit code means "no run"; any other 404 is about this device's binding.
592
+ if (error instanceof CliError && error.httpStatus === 404 && error.code === 'native_local_run_not_found')
593
+ return null;
594
+ throw translated(error);
595
+ }
596
+ return runReceipt(raw, campaignId, 'readRun', null);
597
+ },
598
+ async submitRun(campaignId, event, signal) {
599
+ const services = Array.isArray(event?.services) ? event.services : [];
600
+ const aliases = services.map((item) => item?.service);
601
+ const launches = services.map((item) => item?.launch_id);
602
+ // Root's local-run-lifecycle.v1: full snapshot, unique aliases and launch ids, an exit code exactly when reaped.
603
+ if (!isUuid(event?.run_id) || !isUuid(event.request_key) || !count(event.sequence, 1) || !['register', 'observe', 'close'].includes(event.action)
604
+ || services.length < 1 || services.length > LOCAL_RUN_MAX_SERVICES || new Set(aliases).size !== aliases.length
605
+ || new Set(launches).size !== launches.length || !services.every((item) => READINESS_ALIAS.test(item.service) && isUuid(item.launch_id)
606
+ && LOCAL_RUN_SERVICE_STATES.has(item.state) && (item.state === 'reaped'
607
+ ? Number.isSafeInteger(item.exit_code) && item.exit_code >= -255 && item.exit_code <= 255
608
+ : item.exit_code === null))
609
+ || (event.action === 'register' && (event.sequence !== 1 || !services.every((item) => item.state === 'planned')))) {
610
+ throw new CliError('native_local_run_invalid', 'The local run event is malformed. Nothing was submitted.');
611
+ }
612
+ const path = runPath('submitRun', campaignId);
613
+ const payload = {
614
+ schema: 'firedrill.native-local-run-lifecycle.v1', run_id: event.run_id, campaign_id: campaignId, device_id: deviceId('submitRun'),
615
+ descriptor_digest: scope.campaign?.descriptor_sha256, request_key: event.request_key, sequence: event.sequence, action: event.action,
616
+ services: services.map((item) => ({ service: item.service, launch_id: item.launch_id, state: item.state, exit_code: item.exit_code })),
617
+ };
618
+ // Ed25519 is deterministic: a re-sent event is byte-identical, so root replays its original receipt.
619
+ const receipt = runReceipt(await signedPost(path, payload, signal, 'submitRun'), campaignId, 'submitRun', event.run_id);
620
+ // A lifecycle receipt (first or replayed) carries exactly the snapshot of the event it confirms.
621
+ const sent = new Map(payload.services.map((item) => [item.service, item]));
622
+ if (receipt.sequence !== event.sequence || receipt.descriptor_digest !== payload.descriptor_digest || receipt.services.length !== sent.size
623
+ || !receipt.services.every((item) => {
624
+ const own = sent.get(item.service);
625
+ return own !== undefined && own.launch_id === item.launch_id && own.state === item.state && own.exit_code === item.exit_code;
626
+ })) {
627
+ throw receiptInvalid('the local run');
628
+ }
629
+ return receipt;
630
+ },
631
+ async closeSession(_sessionId, signal) {
632
+ await executor('closeSession').pulse('stopped', signal).catch((error) => { throw translated(error); });
633
+ },
634
+ async targetDispatchComplete(signal) {
635
+ if (!scope.campaign)
636
+ return false;
637
+ const detail = await campaignDetail(scope.campaign.id, signal);
638
+ if (detail.target_dispatch_complete === undefined)
639
+ return false;
640
+ if (typeof detail.target_dispatch_complete !== 'boolean')
641
+ throw receiptInvalid('the campaign dispatch closure');
642
+ return detail.target_dispatch_complete;
643
+ },
644
+ async targetWorkComplete(signal) {
645
+ if (!scope.campaign)
646
+ return false;
647
+ const detail = await campaignDetail(scope.campaign.id, signal);
648
+ if (detail.target_work_complete === undefined)
649
+ return false;
650
+ if (typeof detail.target_work_complete !== 'boolean')
651
+ throw receiptInvalid('the campaign target-work closure');
652
+ return detail.target_work_complete;
653
+ },
654
+ async claimAssignment(_sessionId, signal, claim = {}) {
655
+ const device = deviceId('claimAssignment');
656
+ // 1. An offer already made to this device (by this CLI, the web app or MCP), soonest-expiring live one first.
657
+ // A companion follows the selected campaign over its one channel, so it takes only that campaign's offers
658
+ // (root names each offer's campaign; one that names none is skipped); another campaign's offer is left for
659
+ // that campaign's companion. One bound to the campaign's local run takes them only once the run is
660
+ // registered: root refuses a registration after this device has an offer in the campaign.
661
+ if (!claim.campaignOnly || scope.campaign) {
662
+ let listed;
663
+ try {
664
+ listed = await api(`/api/workspaces/${scope.workspace}/native-executors/${device}/assignments?state=offered&page=1&page_size=${OFFER_PAGE}`, options(signal));
665
+ }
666
+ catch (error) {
667
+ throw translated(error);
668
+ }
669
+ const page = record(listed);
670
+ if (!page || !Array.isArray(page.results))
671
+ throw receiptInvalid('the offered drills');
672
+ const now = Date.now();
673
+ const live = page.results.map((row) => offerRow(row, device, null, null))
674
+ .filter((offer) => offer.state === 'offered' && Date.parse(offer.offer_expires_at) > now && !rejected.has(offer.assignment_id)
675
+ && (!scope.campaign || offer.campaign_id === scope.campaign.id)
676
+ && (!claim.campaignOnly || (offer.campaign_id === scope.campaign?.id && claim.runRegistered?.() === true)))
677
+ .sort((a, b) => Date.parse(a.offer_expires_at) - Date.parse(b.offer_expires_at));
678
+ // The offer an unanswered slot request created (same campaign and requested deadline) is now taken from the
679
+ // listing, so that slot is never asked for again: a later replay would only return it in its current state.
680
+ const pendingSlot = unansweredSlot;
681
+ const asked = pendingSlot === null ? undefined : offerDeadlines.get(pendingSlot);
682
+ if (pendingSlot !== null && asked !== undefined
683
+ && live.some((offer) => offer.campaign_id === scope.campaign?.id && Date.parse(offer.deadline_at) === Date.parse(asked))) {
684
+ offerDeadlines.delete(pendingSlot);
685
+ unansweredSlot = null;
686
+ }
687
+ if (live.length)
688
+ return track(live[0]);
689
+ }
690
+ // 2. With a selected campaign, ask for an offer of its next waiting slot to this device.
691
+ // Every offer consumes a drill, so the campaign must first be shown to run the approved descriptor.
692
+ if (!scope.campaign)
693
+ return null;
694
+ const campaignId = scope.campaign.id;
695
+ if (!campaignConfirmed) {
696
+ await campaignDetail(campaignId, signal);
697
+ campaignConfirmed = true;
698
+ }
699
+ // The caller may need to act first (root registers a local run only before this device's first offer).
700
+ if (claim.beforeOffer && !await claim.beforeOffer(signal))
701
+ return null;
702
+ // An offer root may have made without this companion seeing it is no longer a waiting slot (and a
703
+ // campaign-bound companion skips listed offers), so it is asked for again; root replays it.
704
+ let slotId = unansweredSlot;
705
+ const replaying = slotId !== null;
706
+ if (!slotId) {
707
+ let waiting;
708
+ try {
709
+ waiting = await api(`/api/workspaces/${scope.workspace}/native/campaigns/${campaignId}/slots?state=waiting&page=1&page_size=1`, options(signal));
710
+ }
711
+ catch (error) {
712
+ throw translated(error);
713
+ }
714
+ const slots = record(waiting);
715
+ if (!slots || !Array.isArray(slots.results))
716
+ throw receiptInvalid('the waiting drill slots');
717
+ if (!slots.results.length)
718
+ return null;
719
+ const slot = record(slots.results[0]);
720
+ if (!slot || !isUuid(slot.slot_id) || slot.campaign_id !== campaignId || slot.state !== 'waiting')
721
+ throw receiptInvalid('the waiting drill slots');
722
+ slotId = slot.slot_id;
723
+ }
724
+ const deadline = offerDeadlines.get(slotId) ?? new Date(Date.now() + OFFER_DEADLINE_MS).toISOString();
725
+ offerDeadlines.set(slotId, deadline);
726
+ let raw;
727
+ try {
728
+ raw = await retryTransient(() => api(`/api/workspaces/${scope.workspace}/native/campaigns/${campaignId}/slots/${slotId}/offer`, { method: 'POST', body: { device_id: device, deadline_at: deadline }, ...options(signal) }), { signal, attempts: 3 });
729
+ }
730
+ catch (error) {
731
+ // Unanswered: the offer may exist, so the same request is repeated first next time. Answered: nothing to repeat.
732
+ const answered = error instanceof CliError && error.httpStatus !== null && !isTransient(error);
733
+ unansweredSlot = answered ? null : slotId;
734
+ // An answered request created no offer to replay, so a later one for this slot takes a fresh deadline.
735
+ if (answered)
736
+ offerDeadlines.delete(slotId);
737
+ // Another executor took the slot, a parallel limit is reached, or the campaign is not runnable now. A slot
738
+ // just listed that root already offered elsewhere (another device, the web app or MCP) answers
739
+ // `idempotency_conflict`; only for this client's own unanswered request does that conflict mean something is wrong.
740
+ if (error instanceof CliError && error.httpStatus === 409
741
+ && (NO_OFFER_CODES.has(error.code) || (!replaying && error.code === 'idempotency_conflict')))
742
+ return null;
743
+ throw translated(error);
744
+ }
745
+ unansweredSlot = null;
746
+ offerDeadlines.delete(slotId);
747
+ const offer = offerRow(raw, device, campaignId, slotId);
748
+ if (Date.parse(offer.deadline_at) !== Date.parse(deadline))
749
+ throw receiptInvalid('the drill offer');
750
+ // Root replays an earlier offer in its current state: one already taken, expired or ended is nothing to claim.
751
+ if (offer.state !== 'offered')
752
+ return null;
753
+ return track(offer);
754
+ },
755
+ async acceptAssignment(offer, signal) {
756
+ track(offer);
757
+ let raw;
758
+ try {
759
+ raw = await control(offer, 'accept', signal);
760
+ }
761
+ catch (error) {
762
+ // An answered refusal (offer expired, state changed, terminal) means not accepted; nothing started.
763
+ // That offer is not listed to this companion again, so it is never re-accepted in a loop.
764
+ if (error instanceof CliError && error.httpStatus === 409 && !isTransient(error)) {
765
+ remember(offer.assignment_id);
766
+ return { accepted: false, reason: error.code, receipt: null };
767
+ }
768
+ throw error;
769
+ }
770
+ const receipt = offerRow(raw, offer.device_id, offer.campaign_id, offer.slot_id);
771
+ if (receipt.assignment_id !== offer.assignment_id || receipt.state !== 'accepted') {
772
+ remember(offer.assignment_id);
773
+ return { accepted: false, reason: 'native_offer_not_accepted', receipt };
774
+ }
775
+ return { accepted: true, reason: null, receipt };
776
+ },
777
+ async cancelExecution(assignment, signal) {
778
+ await control(assignment, 'cancel', signal);
779
+ },
780
+ async rejectAssignment(offer, signal) {
781
+ remember(offer.assignment_id);
782
+ track(offer);
783
+ await control(offer, 'reject', signal);
784
+ },
785
+ // Target actions: participant and scripted messages, tasks and signed events. Listing carries
786
+ // no input; the claim returns it once.
787
+ async nextSteps(assignment, _after, signal) {
788
+ const base = assignmentPath('nextSteps', assignment.assignment_id, '/actions');
789
+ const steps = [];
790
+ let status = null;
791
+ for (let page = 1; page <= ACTION_PAGES; page++) {
792
+ let raw;
793
+ try {
794
+ raw = record(await api(`${base}?state=offered&page=${page}`, options(signal)));
795
+ }
796
+ catch (error) {
797
+ throw translated(error);
798
+ }
799
+ if (!raw || !Array.isArray(raw.results) || typeof raw.count !== 'number')
800
+ throw receiptInvalid('the target actions');
801
+ // Root's delivery status is on every page; the first page's read is the one used.
802
+ status ??= deliveryStatus(raw);
803
+ for (const row of raw.results) {
804
+ const action = record(row);
805
+ if (!action || !isUuid(action.action_id) || action.assignment_id !== assignment.assignment_id
806
+ || action.attempt_id !== assignment.attempt_id || !Number.isSafeInteger(action.sequence) || action.sequence < 1
807
+ || typeof action.input_digest !== 'string' || !SHA256_HEX.test(action.input_digest)
808
+ || typeof action.dispatch_available !== 'boolean')
809
+ throw receiptInvalid('the target actions');
810
+ // A root that predates generic actions lists participant messages only, without a kind.
811
+ const kind = action.kind === undefined ? 'participant_message' : action.kind;
812
+ if (!TARGET_ACTION_KINDS.includes(kind)
813
+ || (kind === 'participant_message' ? !isUuid(action.participant_step_id) : action.participant_step_id !== null)) {
814
+ throw receiptInvalid('the target actions');
815
+ }
816
+ // Only an action root would dispatch now, in this attempt's lease and generation, is a step. Root numbers
817
+ // participant turns and its other actions separately, so a sequence is not a cursor: `after` filters nothing,
818
+ // and the runner tracks the actions it handled by id.
819
+ if (!action.dispatch_available || action.state !== 'offered'
820
+ || action.lease_epoch !== assignment.lease_epoch || action.state_generation !== assignment.state_generation)
821
+ continue;
822
+ const actionKind = kind;
823
+ const payload = placeholderPayload(actionKind, action.action_id, participantSession(assignment));
824
+ steps.push({ step_id: action.action_id, sequence: action.sequence, kind: payload.kind, intent_id: action.action_id,
825
+ idempotent: false, payload, deadline_ms: 0, input_digest: action.input_digest, action_kind: actionKind });
826
+ }
827
+ if (raw.next === null || raw.next === undefined)
828
+ break;
829
+ }
830
+ const delivery = status ?? { done: false, cancelled: false, ending_reason: null, schedule: null };
831
+ // Root reports an ending only with no offered, claimed or uncertain action; offered steps always run first.
832
+ return steps.length ? { ...delivery, steps, done: false, ending_reason: null, schedule: null } : { ...delivery, steps };
833
+ },
834
+ async stepStarted(assignment, step, signal) {
835
+ if (step.kind !== STEP_KIND[step.action_kind ?? 'participant_message'] || step.payload.kind !== step.kind || !isUuid(step.step_id)
836
+ || typeof step.input_digest !== 'string' || !SHA256_HEX.test(step.input_digest)) {
837
+ throw new CliError('native_target_action_invalid', 'The target action to claim has a malformed id, kind or input digest. Nothing was submitted.');
838
+ }
839
+ let requestKey = claimKeys.get(actionKey(assignment, step.step_id));
840
+ if (!requestKey) {
841
+ requestKey = randomUUID();
842
+ claimKeys.set(actionKey(assignment, step.step_id), requestKey);
843
+ }
844
+ const payload = { schema: 'firedrill.native-target-control.v1', ...fenceOf(assignment), action_id: step.step_id,
845
+ action: 'claim', request_key: requestKey, input_digest: step.input_digest };
846
+ const raw = record(await signedPost(assignmentPath('stepStarted', assignment.assignment_id, `/actions/${step.step_id}/control`), payload, signal, 'stepStarted'));
847
+ if (!raw || raw.action_id !== step.step_id || typeof raw.recovered !== 'boolean')
848
+ throw receiptInvalid('the target action claim');
849
+ if (raw.recovered) {
850
+ // Root retained an earlier claim: the step may already have run. Never invoke it again.
851
+ if (raw.dispatch !== null || (raw.dispatch_canonical !== null && raw.dispatch_canonical !== undefined))
852
+ throw receiptInvalid('the target action claim');
853
+ return { recovered: true, payload: null };
854
+ }
855
+ // Root's exact frozen bytes are authoritative: they are what input_digest hashes. Re-serializing the
856
+ // parsed dispatch here would differ from Python for whole-number floats, exponents and key order.
857
+ const dispatch = frozenDispatch(raw.dispatch_canonical, step.input_digest);
858
+ const kind = step.action_kind ?? 'participant_message';
859
+ if (!dispatch || raw.state !== 'claimed'
860
+ || dispatch.action_id !== step.step_id || dispatch.assignment_id !== assignment.assignment_id
861
+ || dispatch.attempt_id !== assignment.attempt_id || dispatch.lease_epoch !== assignment.lease_epoch
862
+ || dispatch.state_generation !== assignment.state_generation || dispatch.sequence !== step.sequence
863
+ || dispatch.kind !== kind
864
+ // The parsed compatibility copy, when sent, must hold the same values as the frozen bytes.
865
+ || (raw.dispatch !== undefined && (!record(raw.dispatch) || rootCanonical(raw.dispatch) !== rootCanonical(dispatch)))) {
866
+ throw receiptInvalid('the target action claim');
867
+ }
868
+ const dispatched = dispatchedPayload(dispatch, kind, step.payload.kind === 'event' ? participantSession(assignment) : step.payload.session_key);
869
+ if (!dispatched || dispatched.kind !== step.kind)
870
+ throw receiptInvalid('the target action claim');
871
+ return { recovered: false, payload: dispatched, claim_key: requestKey };
872
+ },
873
+ async abandonStep(assignment, step, reason, signal) {
874
+ const claimKey = claimKeys.get(actionKey(assignment, step.step_id));
875
+ // Only this client's own claim, of the exact input it was offered, can be withdrawn.
876
+ if (!claimKey || !isUuid(step.step_id) || typeof step.input_digest !== 'string' || !SHA256_HEX.test(step.input_digest)
877
+ || (reason !== 'client_refused' && reason !== 'not_delivered')) {
878
+ throw new CliError('native_target_withdrawal_invalid', 'Only a step this client claimed can be withdrawn. Nothing was submitted.');
879
+ }
880
+ let requestKey = abandonKeys.get(actionKey(assignment, step.step_id));
881
+ if (!requestKey) {
882
+ requestKey = randomUUID();
883
+ abandonKeys.set(actionKey(assignment, step.step_id), requestKey);
884
+ }
885
+ const payload = { schema: 'firedrill.native-target-control.v1', ...fenceOf(assignment), action_id: step.step_id,
886
+ action: 'abandon', request_key: requestKey, claim_key: claimKey, input_digest: step.input_digest, reason };
887
+ const raw = record(await signedPost(assignmentPath('abandonStep', assignment.assignment_id, `/actions/${step.step_id}/control`), payload, signal, 'abandonStep'));
888
+ if (!raw || Object.keys(raw).sort().join(',') !== 'action_id,delivery,intent_digest,reason,request_key,schema,state,withdrawn_at'
889
+ || raw.schema !== 'firedrill.native-target-withdrawal.v1' || raw.action_id !== step.step_id || raw.request_key !== requestKey
890
+ || typeof raw.intent_digest !== 'string' || !SHA256_HEX.test(raw.intent_digest) || raw.state !== 'cancelled'
891
+ || raw.reason !== reason || raw.delivery !== 'device_declared_not_delivered'
892
+ || typeof raw.withdrawn_at !== 'string' || !Number.isFinite(Date.parse(raw.withdrawn_at))) {
893
+ throw receiptInvalid('the target action withdrawal');
894
+ }
895
+ return raw;
896
+ },
897
+ async renewLease(assignment, signal) {
898
+ const receipt = await control(assignment, 'heartbeat', signal);
899
+ return { lease_expires_at: receipt.lease_expires_at,
900
+ state: typeof receipt.state === 'string' && /^[a-z_]{1,24}$/.test(receipt.state) ? receipt.state : null };
901
+ },
902
+ async uploadArtifact(assignment, upload, signal) {
903
+ // A screenshot is a metadata-free PNG with root's capture declaration; everything else is filtered JSON.
904
+ const image = upload.media_type === 'image/png';
905
+ const capture = image ? upload.capture : null;
906
+ if (!isUuid(upload.request_key) || !SHA256_HEX.test(upload.sha256) || !(upload.bytes instanceof Uint8Array) || upload.bytes.byteLength < 1
907
+ || (image ? upload.role !== 'screenshot.observed' || !capture || capture.consent !== true || capture.source !== 'playwright'
908
+ || capture.masking_applied !== true || !SHA256_HEX.test(capture.masking_policy_sha256)
909
+ || upload.bytes.byteLength > PNG_MAX_BYTES || !isMetadataFreePng(upload.bytes)
910
+ : upload.media_type !== 'application/json')) {
911
+ throw new CliError('native_artifact_invalid', 'The evidence record is malformed. Nothing was submitted.');
912
+ }
913
+ const declaration = { schema: 'firedrill.native-artifact.v1', ...fenceOf(assignment), request_key: upload.request_key,
914
+ role: upload.role, media_kind: upload.media_type, size: upload.bytes.byteLength, sha256: upload.sha256,
915
+ privacy_profile: image ? 'client-masked-png-v1' : 'client-filtered-v1' };
916
+ if (capture)
917
+ declaration.capture = { consent: true, source: 'playwright', masking_applied: true, masking_policy_sha256: capture.masking_policy_sha256 };
918
+ const declarationSha256 = digestOf(declaration);
919
+ const declared = record(await signedPost(assignmentPath('uploadArtifact', assignment.assignment_id, '/artifacts'), declaration, signal, 'uploadArtifact'));
920
+ const artifact = record(declared?.artifact);
921
+ if (!artifact || !isUuid(artifact.artifact_id) || artifact.assignment_id !== assignment.assignment_id
922
+ || artifact.request_key !== upload.request_key || artifact.declaration_sha256 !== declarationSha256
923
+ || artifact.sha256 !== upload.sha256 || artifact.size !== upload.bytes.byteLength || artifact.role !== upload.role
924
+ || artifact.media_kind !== upload.media_type)
925
+ throw receiptInvalid('the evidence declaration');
926
+ const artifactId = artifact.artifact_id;
927
+ if (artifact.state === 'committed')
928
+ return { artifact_id: artifactId };
929
+ const access = record(declared?.upload);
930
+ if (artifact.state !== 'declared' || !access)
931
+ throw receiptInvalid('the evidence declaration');
932
+ await putEvidence(access, upload.bytes, signal);
933
+ const commit = { schema: 'firedrill.native-artifact-commit.v1', ...fenceOf(assignment), request_key: upload.request_key,
934
+ artifact_id: artifactId, declaration_sha256: declarationSha256 };
935
+ const committed = record(await signedPost(assignmentPath('uploadArtifact', assignment.assignment_id, '/artifacts/commit'), commit, signal, 'uploadArtifact'));
936
+ if (!committed || committed.artifact_id !== artifactId || committed.state !== 'committed' || committed.sha256 !== upload.sha256) {
937
+ throw receiptInvalid('the evidence commit');
938
+ }
939
+ return { artifact_id: artifactId };
940
+ },
941
+ async submitObservations(batch, signal) {
942
+ const raw = record(await signedPost(assignmentPath('submitObservations', batch.assignment_id, '/observations'), batch, signal, 'submitObservations'));
943
+ const acknowledged = raw?.acknowledged_sequence;
944
+ if (!raw || raw.assignment_id !== batch.assignment_id || raw.attempt_id !== batch.attempt_id || raw.batch_id !== batch.batch_id
945
+ || typeof acknowledged !== 'number' || !Number.isSafeInteger(acknowledged) || acknowledged < 0
946
+ || raw.sha256 !== digestOf(batch))
947
+ throw receiptInvalid('the observation batch');
948
+ if (raw.lease_epoch !== batch.lease_epoch || raw.state_generation !== batch.state_generation) {
949
+ throw nativeError('stale_generation', 'The observation acknowledgement belongs to another lease or state generation.');
950
+ }
951
+ return { acknowledged };
952
+ },
953
+ /** Completes a claimed target action with its acknowledged v2 reply event; a byte-identical retry returns the same completion. */
954
+ async completeStep(assignment, result, signal) {
955
+ const reply = result.reply_event_id;
956
+ if (!isUuid(result.step_id) || typeof reply !== 'string' || !NATIVE_ID.test(reply)) {
957
+ throw new CliError('native_target_completion_invalid', 'A target action completes only with its own id and its acknowledged reply event. Nothing was submitted.');
958
+ }
959
+ const payload = { schema: 'firedrill.native-target-control.v1', ...fenceOf(assignment), action_id: result.step_id,
960
+ action: 'complete', reply_event_id: reply };
961
+ const raw = record(await signedPost(assignmentPath('completeStep', assignment.assignment_id, `/actions/${result.step_id}/control`), payload, signal, 'completeStep'));
962
+ if (!raw || raw.action_id !== result.step_id || raw.state !== 'completed' || raw.reply_event_id !== reply) {
963
+ throw receiptInvalid('the target action completion');
964
+ }
965
+ },
966
+ async recordReadiness(assignment, readiness, signal) {
967
+ const key = `${assignment.assignment_id}\0${assignment.attempt_id}`;
968
+ let state = readinessRecords.get(key);
969
+ // A different sequence means the earlier record can no longer be referenced: a new one is signed.
970
+ if (!state || state.observation_sequence !== readiness.observation_sequence) {
971
+ const services = readiness.services.map((item) => item.service);
972
+ const resets = readiness.state_resets.map((item) => item.alias);
973
+ if (!count(readiness.observation_sequence, 1) || !SHA256_HEX.test(readiness.descriptor_digest)
974
+ || services.length < 1 || services.length > READINESS_MAX_SERVICES || new Set(services).size !== services.length
975
+ || !readiness.services.every(readinessServiceValid) || resets.length > READINESS_MAX_RESETS
976
+ || new Set(resets).size !== resets.length || !readiness.state_resets.every(readinessResetValid)) {
977
+ throw new CliError('native_readiness_invalid', 'The drill readiness is malformed. Nothing was submitted.');
978
+ }
979
+ // Exactly the schema's fields.
980
+ const payload = { schema: 'firedrill.native-execution-readiness.v1', request_key: randomUUID(), ...fenceOf(assignment),
981
+ observation_sequence: readiness.observation_sequence, descriptor_digest: readiness.descriptor_digest,
982
+ services: readiness.services.map((item) => ({ service: item.service, launch_id: item.launch_id, ownership: item.ownership,
983
+ run_id: item.run_id, probe: item.probe.kind === 'http' ? { kind: 'http', status_code: item.probe.status_code }
984
+ : item.probe.kind === 'process' ? { kind: 'process', observation: 'running' }
985
+ : { kind: item.probe.kind, response_sha256: item.probe.response_sha256 } })),
986
+ state_resets: readiness.state_resets.map((item) => ({ alias: item.alias, reset: item.reset, hook_sha256: item.hook_sha256,
987
+ evidence_artifact_id: item.evidence_artifact_id, evidence_sha256: item.evidence_sha256 })) };
988
+ const document = await executor('recordReadiness').sign(payload, signal);
989
+ const bytes = Buffer.from(rootCanonical(document), 'utf8');
990
+ if (bytes.byteLength > EVIDENCE_RECORD_MAX_BYTES)
991
+ throw new CliError('native_readiness_invalid', 'The drill readiness is too large. Nothing was submitted.');
992
+ state = { upload_key: randomUUID(), bytes, sha256: createHash('sha256').update(bytes).digest('hex'),
993
+ observation_sequence: readiness.observation_sequence, artifact_id: null };
994
+ readinessRecords.set(key, state);
995
+ }
996
+ if (!state.artifact_id) {
997
+ const committed = await plane.uploadArtifact(assignment, { request_key: state.upload_key, role: 'driver.started',
998
+ media_type: 'application/json', sha256: state.sha256, bytes: state.bytes }, signal);
999
+ if (!isUuid(committed?.artifact_id))
1000
+ throw receiptInvalid('the drill readiness record');
1001
+ state.artifact_id = committed.artifact_id;
1002
+ }
1003
+ return { artifact_id: state.artifact_id, sha256: state.sha256, size: state.bytes.byteLength,
1004
+ observation_sequence: state.observation_sequence };
1005
+ },
1006
+ async dependencyAccess(assignment, alias, signal) {
1007
+ if (typeof alias !== 'string' || !/^[a-z][a-z0-9_-]{0,63}$/.test(alias)) {
1008
+ throw new CliError('native_dependency_invalid', 'The dependency alias is malformed. Nothing was requested.');
1009
+ }
1010
+ const raw = record(await signedPost(assignmentPath('dependencyAccess', assignment.assignment_id, '/dependency-access'), { schema: 'firedrill.native-dependency-access.v1', ...fenceOf(assignment), dependency_alias: alias }, signal, 'dependencyAccess'));
1011
+ const auth = record(raw?.authorization);
1012
+ const origin = (value) => {
1013
+ try {
1014
+ const url = new URL(String(value));
1015
+ return url.protocol === 'https:' && !url.username && !url.password && !url.search && !url.hash ? url : null;
1016
+ }
1017
+ catch {
1018
+ return null;
1019
+ }
1020
+ };
1021
+ const invoke = origin(raw?.invoke_url), mcp = origin(raw?.mcp_url);
1022
+ const operations = Array.isArray(raw?.operations) ? raw.operations : null;
1023
+ if (!raw || raw.schema !== 'firedrill.native-dependency-access.v1' || raw.assignment_id !== assignment.assignment_id
1024
+ || raw.attempt_id !== assignment.attempt_id || raw.lease_epoch !== assignment.lease_epoch
1025
+ || raw.state_generation !== assignment.state_generation || raw.dependency_alias !== alias
1026
+ || typeof raw.binding_digest !== 'string' || !SHA256_HEX.test(raw.binding_digest)
1027
+ || typeof raw.service_key !== 'string' || !raw.service_key || raw.service_key.length > 200
1028
+ || !operations || operations.length < 1 || !operations.every((item) => typeof item === 'string' && item.length > 0)
1029
+ || !auth || auth.token_type !== 'Bearer' || typeof auth.token !== 'string' || !auth.token || auth.token.length > 100_000
1030
+ || !Number.isSafeInteger(auth.expires_at) || !invoke || !mcp || invoke.origin !== mcp.origin
1031
+ || invoke.pathname !== '/v1/invoke' || mcp.pathname !== `/mcp/${encodeURIComponent(raw.service_key)}`) {
1032
+ throw receiptInvalid('the dependency access');
1033
+ }
1034
+ return { dependency_alias: alias, binding_digest: raw.binding_digest, service_key: raw.service_key,
1035
+ operations: operations, token: new SecretValue(auth.token, `dependency:${alias}`),
1036
+ expires_at: auth.expires_at, invoke_url: invoke.href, mcp_url: mcp.href };
1037
+ },
1038
+ async stopExecution(assignment, signal) {
1039
+ const key = `${assignment.assignment_id}\0${assignment.attempt_id}`;
1040
+ let state = stops.get(key);
1041
+ if (!state) {
1042
+ const payload = { schema: 'firedrill.native-execution-stop.v1', request_key: randomUUID(), ...fenceOf(assignment) };
1043
+ state = { request_key: payload.request_key, document: await executor('stopExecution').sign(payload, signal) };
1044
+ stops.set(key, state);
1045
+ }
1046
+ let raw;
1047
+ try {
1048
+ raw = record(await api(assignmentPath('stopExecution', assignment.assignment_id, '/stop'), { method: 'POST', body: state.document, ...options(signal) }));
1049
+ }
1050
+ catch (error) {
1051
+ throw translated(error);
1052
+ }
1053
+ if (!raw || raw.request_key !== state.request_key || raw.assignment_id !== assignment.assignment_id
1054
+ || raw.attempt_id !== assignment.attempt_id || raw.lease_epoch !== assignment.lease_epoch
1055
+ || raw.state_generation !== assignment.state_generation || (raw.last_action_id !== null && !isUuid(raw.last_action_id))
1056
+ || raw.dispatch_stopped !== true || typeof raw.cleanup_complete !== 'boolean') {
1057
+ throw receiptInvalid('the drill stop');
1058
+ }
1059
+ return { request_key: state.request_key, last_action_id: raw.last_action_id, dispatch_stopped: true,
1060
+ cleanup_complete: raw.cleanup_complete };
1061
+ },
1062
+ async recordClose(assignment, close, signal) {
1063
+ const key = `${assignment.assignment_id}\0${assignment.attempt_id}`;
1064
+ let state = closes.get(key);
1065
+ if (state && (state.observation_sequence !== close.observation_sequence || state.driver_outcome !== close.driver_outcome)) {
1066
+ throw new CliError('native_close_changed', 'A different close was already prepared for this drill. Nothing further was sent.');
1067
+ }
1068
+ if (!state) {
1069
+ const services = close.processes.map((item) => item.service);
1070
+ // Root's execution-close.v1 variants; "not started" only for a drill that did not complete.
1071
+ const valid = (item) => /^[a-zA-Z][a-zA-Z0-9_.-]{0,79}$/.test(item.service) && (item.ownership === 'owned'
1072
+ ? item.close_kind === 'owned_group_reaped' && isUuid(item.launch_id) && Number.isSafeInteger(item.exit_code) && item.exit_code >= -255 && item.exit_code <= 255
1073
+ : item.ownership === 'attached' ? item.close_kind === 'transport_detached' && isUuid(item.launch_id)
1074
+ : item.ownership === 'owned_run' ? item.close_kind === 'transport_detached' && isUuid(item.launch_id) && isUuid(item.run_id)
1075
+ : item.ownership === 'none' && item.close_kind === 'not_started' && close.driver_outcome !== 'completed');
1076
+ const summary = close.client_summary;
1077
+ const summaryValid = summary === undefined || (STOP_REASONS.includes(summary.stop_reason)
1078
+ && count(summary.steps_started, 0) && count(summary.steps_completed, 0) && summary.steps_completed <= summary.steps_started
1079
+ && (summary.dependency_capture_complete === undefined || typeof summary.dependency_capture_complete === 'boolean')
1080
+ && (summary.dependency_capture_records_complete === undefined || typeof summary.dependency_capture_records_complete === 'boolean'));
1081
+ if (!count(close.observation_sequence, 1) || !['completed', 'failed', 'cancelled'].includes(close.driver_outcome)
1082
+ || (close.last_action_id !== null && !isUuid(close.last_action_id)) || close.processes.length > 128
1083
+ || new Set(services).size !== services.length || !close.processes.every(valid) || !summaryValid) {
1084
+ throw new CliError('native_close_invalid', 'The drill close is malformed. Nothing was submitted.');
1085
+ }
1086
+ // Exactly the schema's fields for each variant.
1087
+ const processes = close.processes.map((item) => item.ownership === 'owned'
1088
+ ? { service: item.service, launch_id: item.launch_id, ownership: item.ownership, close_kind: item.close_kind, exit_code: item.exit_code }
1089
+ : item.ownership === 'attached' ? { service: item.service, launch_id: item.launch_id, ownership: item.ownership, close_kind: item.close_kind }
1090
+ : item.ownership === 'owned_run' ? { service: item.service, launch_id: item.launch_id, run_id: item.run_id, ownership: item.ownership,
1091
+ close_kind: item.close_kind }
1092
+ : { service: item.service, ownership: item.ownership, close_kind: item.close_kind });
1093
+ // One signed document per attempt: a retry re-sends the same bytes under the same keys.
1094
+ const payload = { schema: 'firedrill.native-execution-close.v1', request_key: randomUUID(), ...fenceOf(assignment),
1095
+ observation_sequence: close.observation_sequence, last_action_id: close.last_action_id, driver_outcome: close.driver_outcome, processes,
1096
+ ...(summary ? { client_summary: { stop_reason: summary.stop_reason, steps_started: summary.steps_started,
1097
+ steps_completed: summary.steps_completed,
1098
+ ...(summary.dependency_capture_complete === undefined ? {} : { dependency_capture_complete: summary.dependency_capture_complete }),
1099
+ ...(summary.dependency_capture_records_complete === undefined ? {} : { dependency_capture_records_complete: summary.dependency_capture_records_complete }) } } : {}) };
1100
+ const document = await executor('recordClose').sign(payload, signal);
1101
+ const bytes = Buffer.from(rootCanonical(document), 'utf8');
1102
+ if (bytes.byteLength > CLOSE_MAX_BYTES)
1103
+ throw new CliError('native_close_invalid', 'The drill close is too large. Nothing was submitted.');
1104
+ state = { request_key: payload.request_key, upload_key: randomUUID(), bytes, sha256: createHash('sha256').update(bytes).digest('hex'),
1105
+ observation_sequence: close.observation_sequence, driver_outcome: close.driver_outcome, artifact_id: null };
1106
+ closes.set(key, state);
1107
+ }
1108
+ if (!state.artifact_id) {
1109
+ const committed = await plane.uploadArtifact(assignment, { request_key: state.upload_key, role: `driver.${state.driver_outcome}`,
1110
+ media_type: 'application/json', sha256: state.sha256, bytes: state.bytes }, signal);
1111
+ if (!isUuid(committed?.artifact_id))
1112
+ throw receiptInvalid('the drill close record');
1113
+ state.artifact_id = committed.artifact_id;
1114
+ }
1115
+ await retainClose(assignment, state);
1116
+ return { artifact_id: state.artifact_id, sha256: state.sha256, kind: `driver.${state.driver_outcome}`,
1117
+ observation_sequence: state.observation_sequence };
1118
+ },
1119
+ async submitClose(assignment, closeRecord, signal) {
1120
+ const state = closes.get(`${assignment.assignment_id}\0${assignment.attempt_id}`);
1121
+ if (!state || state.artifact_id === null || state.artifact_id !== closeRecord.artifact_id) {
1122
+ throw new CliError('native_close_invalid', 'That close was not recorded for this drill. Nothing was submitted.');
1123
+ }
1124
+ const artifactId = state.artifact_id;
1125
+ let raw;
1126
+ try {
1127
+ raw = record(await api(assignmentPath('submitClose', assignment.assignment_id, '/close'), { method: 'POST', body: { close_artifact_id: artifactId }, ...options(signal) }));
1128
+ }
1129
+ catch (error) {
1130
+ throw translated(error);
1131
+ }
1132
+ if (!raw || !isUuid(raw.close_id) || raw.assignment_id !== assignment.assignment_id || raw.close_artifact_id !== artifactId
1133
+ || raw.request_key !== state.request_key || raw.observation_sequence !== state.observation_sequence
1134
+ || raw.driver_outcome !== state.driver_outcome || raw.state !== 'accepted' || !time(raw.accepted_at)) {
1135
+ throw receiptInvalid('the drill close');
1136
+ }
1137
+ await rm(await closeJournalPath(assignment), { force: true });
1138
+ forget(assignment);
1139
+ return { close_id: raw.close_id, close_artifact_id: artifactId, observation_sequence: state.observation_sequence,
1140
+ driver_outcome: state.driver_outcome, state: 'accepted', accepted_at: raw.accepted_at };
1141
+ },
1142
+ async readCleanup(assignment, signal) {
1143
+ let raw;
1144
+ try {
1145
+ raw = record(await api(assignmentPath('readCleanup', assignment.assignment_id, '/cleanup'), options(signal)));
1146
+ }
1147
+ catch (error) {
1148
+ // Root holds no frozen execution for this drill (not fenced, or not captured yet): nothing to report.
1149
+ if (error instanceof CliError && (error.httpStatus === 404 || error.code === 'native_execution_cleanup_unavailable'))
1150
+ return null;
1151
+ throw translated(error);
1152
+ }
1153
+ if (!raw || !isUuid(raw.cleanup_id) || raw.assignment_id !== assignment.assignment_id || raw.attempt_id !== assignment.attempt_id
1154
+ || !count(raw.execution_epoch, 1) || !count(raw.state_generation, 1) || !count(raw.observation_sequence, 0)
1155
+ || (raw.last_action_id !== null && !isUuid(raw.last_action_id)) || !['expired', 'revoked', 'cancelled'].includes(raw.reason)
1156
+ || typeof raw.report_received !== 'boolean' || !count(raw.report_sequence, 0) || raw.report_sequence > CLEANUP_MAX_REPORTS
1157
+ || raw.execution_authorized !== false) {
1158
+ throw receiptInvalid('the cleanup challenge');
1159
+ }
1160
+ return { cleanup_id: raw.cleanup_id, assignment_id: assignment.assignment_id, attempt_id: assignment.attempt_id,
1161
+ execution_epoch: raw.execution_epoch, state_generation: raw.state_generation,
1162
+ observation_sequence: raw.observation_sequence, last_action_id: raw.last_action_id,
1163
+ reason: raw.reason, report_received: raw.report_received,
1164
+ report_sequence: raw.report_sequence };
1165
+ },
1166
+ async submitCleanup(assignment, challenge, processes, signal) {
1167
+ // Root's scope is copied exactly; this client reports only for the execution it ran (same epoch and generation).
1168
+ if (challenge.assignment_id !== assignment.assignment_id || challenge.attempt_id !== assignment.attempt_id
1169
+ || challenge.execution_epoch !== assignment.lease_epoch || challenge.state_generation !== assignment.state_generation) {
1170
+ throw new CliError('native_cleanup_scope_changed', 'The frozen execution is not the one this companion ran, so no cleanup was reported.');
1171
+ }
1172
+ const sequence = challenge.report_sequence + 1;
1173
+ const key = `${assignment.assignment_id}\0${assignment.attempt_id}\0${challenge.cleanup_id}`;
1174
+ let state = cleanups.get(key);
1175
+ // A report already received that this companion did not send (or a later one) is not overwritten.
1176
+ if (state ? state.sequence < challenge.report_sequence : challenge.report_received) {
1177
+ throw new CliError('native_cleanup_reported', 'Cleanup for this drill was already reported. Nothing further was sent.');
1178
+ }
1179
+ if (!state) {
1180
+ if (sequence > CLEANUP_MAX_REPORTS)
1181
+ throw new CliError('native_cleanup_invalid', 'No further cleanup report is accepted for this drill. Nothing was sent.');
1182
+ const services = processes.map((item) => item.service);
1183
+ const valid = (item) => /^[a-zA-Z][a-zA-Z0-9_.-]{0,79}$/.test(item.service) && (item.close_kind === 'unknown'
1184
+ ? (item.launch_id === null || isUuid(item.launch_id)) && ['owned', 'attached', 'owned_run', 'unknown'].includes(item.ownership)
1185
+ : item.ownership === 'owned' ? item.close_kind === 'owned_group_reaped' && isUuid(item.launch_id) && Number.isSafeInteger(item.exit_code)
1186
+ && item.exit_code >= -255 && item.exit_code <= 255
1187
+ : item.ownership === 'attached' ? item.close_kind === 'transport_detached' && isUuid(item.launch_id)
1188
+ : item.ownership === 'owned_run' ? item.close_kind === 'transport_detached' && isUuid(item.launch_id) && isUuid(item.run_id)
1189
+ : item.ownership === 'none' && item.close_kind === 'not_started');
1190
+ if (processes.length > 128 || new Set(services).size !== services.length || !processes.every(valid)) {
1191
+ throw new CliError('native_cleanup_invalid', 'The cleanup report is malformed. Nothing was sent.');
1192
+ }
1193
+ // Exactly the schema's fields for each variant.
1194
+ const entries = processes.map((item) => item.close_kind === 'unknown'
1195
+ ? { service: item.service, launch_id: item.launch_id, ownership: item.ownership, close_kind: item.close_kind }
1196
+ : item.ownership === 'owned' ? { service: item.service, launch_id: item.launch_id, ownership: item.ownership, close_kind: item.close_kind, exit_code: item.exit_code }
1197
+ : item.ownership === 'attached' ? { service: item.service, launch_id: item.launch_id, ownership: item.ownership, close_kind: item.close_kind }
1198
+ : item.ownership === 'owned_run' ? { service: item.service, launch_id: item.launch_id, run_id: item.run_id, ownership: item.ownership, close_kind: item.close_kind }
1199
+ : { service: item.service, ownership: item.ownership, close_kind: item.close_kind });
1200
+ // An unknown end is never reported as stopped: the whole report is then unknown.
1201
+ const outcome = processes.some((item) => item.close_kind === 'unknown') ? 'unknown' : 'cleanup_reported';
1202
+ const payload = { schema: 'firedrill.native-execution-cleanup.v1', cleanup_id: challenge.cleanup_id, request_key: randomUUID(), sequence,
1203
+ assignment_id: challenge.assignment_id, attempt_id: challenge.attempt_id, execution_epoch: challenge.execution_epoch,
1204
+ state_generation: challenge.state_generation, observation_sequence: challenge.observation_sequence, last_action_id: challenge.last_action_id,
1205
+ outcome, processes: entries };
1206
+ state = { request_key: payload.request_key, sequence, outcome, document: await executor('submitCleanup').sign(payload, signal) };
1207
+ cleanups.set(key, state);
1208
+ }
1209
+ let raw;
1210
+ try {
1211
+ raw = record(await api(assignmentPath('submitCleanup', assignment.assignment_id, '/cleanup'), { method: 'POST', body: state.document, ...options(signal) }));
1212
+ }
1213
+ catch (error) {
1214
+ throw translated(error);
1215
+ }
1216
+ if (!raw || raw.cleanup_id !== challenge.cleanup_id || raw.assignment_id !== assignment.assignment_id || raw.request_key !== state.request_key
1217
+ || raw.sequence !== state.sequence || raw.outcome !== state.outcome || raw.process_provenance !== 'customer_device'
1218
+ || raw.execution_authorized !== false || raw.cloud_cleanup_complete !== false
1219
+ || !count(raw.unresolved_actions, 0) || !count(raw.unresolved_participants, 0)) {
1220
+ throw receiptInvalid('the cleanup report');
1221
+ }
1222
+ forget(assignment);
1223
+ return { cleanup_id: challenge.cleanup_id, sequence: state.sequence, outcome: state.outcome,
1224
+ unresolved_actions: raw.unresolved_actions, unresolved_participants: raw.unresolved_participants };
1225
+ },
1226
+ };
1227
+ return plane;
1228
+ }
1229
+ // ---------------------------------------------------------------------------
1230
+ // Error classification.
1231
+ /** Codes that mean the cloud did not answer; the same request may be repeated when it is idempotent. */
1232
+ const TRANSIENT_CODES = new Set(['connection_uncertain', 'response_unavailable', NATIVE_ERRORS.disconnected]);
1233
+ const TRANSIENT_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
1234
+ export function errorCode(error) {
1235
+ return error instanceof CliError ? error.code : null;
1236
+ }
1237
+ /** True when the failure is a connectivity or temporary service condition, not a refusal. */
1238
+ export function isTransient(error) {
1239
+ if (!(error instanceof CliError))
1240
+ return false;
1241
+ if (TRANSIENT_CODES.has(error.code))
1242
+ return true;
1243
+ return error.httpStatus !== null && TRANSIENT_STATUS.has(error.httpStatus);
1244
+ }
1245
+ export function isRevocation(error) {
1246
+ return errorCode(error) === NATIVE_ERRORS.revoked;
1247
+ }
1248
+ /** Lease or generation fence: this attempt may no longer act. */
1249
+ export function isFenced(error) {
1250
+ const code = errorCode(error);
1251
+ return code === NATIVE_ERRORS.lease_expired || code === NATIVE_ERRORS.stale_generation;
1252
+ }
1253
+ export function isAbort(error, signal) {
1254
+ return (signal?.aborted ?? false) || (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError'));
1255
+ }
1256
+ // ---------------------------------------------------------------------------
1257
+ // Backoff, sleep and bounded retry.
1258
+ /** Capped exponential backoff with jitter in [delay/2, delay]. `attempt` starts at 0. */
1259
+ export function backoffMs(attempt, baseMs, capMs) {
1260
+ const exp = Math.min(capMs, baseMs * 2 ** Math.min(Math.max(attempt, 0), 30));
1261
+ return Math.round(exp / 2 + Math.random() * (exp / 2));
1262
+ }
1263
+ /** Interval with jitter in [0.8, 1.0] of `ms`, so the server interval is never exceeded. */
1264
+ export function jitteredInterval(ms) {
1265
+ return Math.max(1, Math.round(ms * (0.8 + Math.random() * 0.2)));
1266
+ }
1267
+ /** Resolves true after `ms`, or false as soon as `signal` aborts. Never rejects. */
1268
+ export function sleep(ms, signal) {
1269
+ if (signal?.aborted)
1270
+ return Promise.resolve(false);
1271
+ return new Promise((resolve) => {
1272
+ let done = false;
1273
+ const finish = (value) => {
1274
+ if (done)
1275
+ return;
1276
+ done = true;
1277
+ clearTimeout(timer);
1278
+ signal?.removeEventListener('abort', onAbort);
1279
+ resolve(value);
1280
+ };
1281
+ const onAbort = () => finish(false);
1282
+ const timer = setTimeout(() => finish(true), Math.max(0, ms));
1283
+ signal?.addEventListener('abort', onAbort, { once: true });
1284
+ });
1285
+ }
1286
+ /**
1287
+ * Repeat `operation` only on transient failures, with capped backoff. Use only
1288
+ * for operations the cloud treats idempotently (same ids/intent). Refusals,
1289
+ * fences, revocation and pending contracts are rethrown immediately.
1290
+ */
1291
+ export async function retryTransient(operation, options) {
1292
+ const attempts = Math.max(1, options.attempts ?? 5);
1293
+ for (let attempt = 0;; attempt++) {
1294
+ try {
1295
+ return await operation(attempt);
1296
+ }
1297
+ catch (error) {
1298
+ if (!isTransient(error) || options.signal.aborted || attempt + 1 >= attempts)
1299
+ throw error;
1300
+ options.onTransient?.(error, attempt);
1301
+ if (!await sleep(backoffMs(attempt, options.baseMs ?? 500, options.capMs ?? 15_000), options.signal))
1302
+ throw error;
1303
+ }
1304
+ }
1305
+ }
1306
+ // ---------------------------------------------------------------------------
1307
+ // Connection gate: pauses new steps while the companion is disconnected.
1308
+ export class ConnectionGate {
1309
+ #connected = true;
1310
+ #waiters = new Set();
1311
+ #listeners = new Set();
1312
+ get connected() { return this.#connected; }
1313
+ /** Mark disconnected. Returns true when this call changed the state. */
1314
+ disconnected() {
1315
+ if (!this.#connected)
1316
+ return false;
1317
+ this.#connected = false;
1318
+ this.#notifyChange();
1319
+ return true;
1320
+ }
1321
+ /** Mark reconnected and release paused waiters. Returns true when this call changed the state. */
1322
+ reconnected() {
1323
+ if (this.#connected)
1324
+ return false;
1325
+ this.#connected = true;
1326
+ for (const resolve of this.#waiters)
1327
+ resolve();
1328
+ this.#waiters.clear();
1329
+ this.#notifyChange();
1330
+ return true;
1331
+ }
1332
+ /** Resolves true once connected, false if `signal` aborts first. */
1333
+ wait(signal) {
1334
+ if (this.#connected)
1335
+ return Promise.resolve(true);
1336
+ if (signal.aborted)
1337
+ return Promise.resolve(false);
1338
+ return new Promise((resolve) => {
1339
+ const onConnected = () => { signal.removeEventListener('abort', onAbort); resolve(true); };
1340
+ const onAbort = () => { this.#waiters.delete(onConnected); resolve(false); };
1341
+ this.#waiters.add(onConnected);
1342
+ signal.addEventListener('abort', onAbort, { once: true });
1343
+ });
1344
+ }
1345
+ /** Subscribe to state changes (used to wake the reconnect loop early). Returns the unsubscribe function. */
1346
+ onChange(listener) {
1347
+ this.#listeners.add(listener);
1348
+ return () => { this.#listeners.delete(listener); };
1349
+ }
1350
+ #notifyChange() {
1351
+ for (const listener of [...this.#listeners])
1352
+ listener();
1353
+ }
1354
+ }