akm-cli 0.9.16 → 0.9.17-alpha.10

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 (403) hide show
  1. package/CHANGELOG.md +2101 -0
  2. package/STABILITY.md +11 -10
  3. package/dist/akm +124 -193
  4. package/dist/akm-migrate +38 -19
  5. package/dist/assets/hints/cli-hints-full.md +6 -7
  6. package/dist/assets/improve-strategies/catchup.json +0 -3
  7. package/dist/assets/improve-strategies/consolidate.json +0 -1
  8. package/dist/assets/improve-strategies/default.json +1 -2
  9. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -2
  10. package/dist/assets/improve-strategies/quick.json +1 -2
  11. package/dist/assets/improve-strategies/reflect-distill.json +1 -2
  12. package/dist/assets/improve-strategies/thorough.json +0 -3
  13. package/dist/assets/prompts/consolidate-pair.md +20 -0
  14. package/dist/assets/prompts/consolidate-system.md +4 -11
  15. package/dist/assets/prompts/retrieval-relevance-judge.md +6 -0
  16. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +20 -20
  17. package/dist/assets/stash-skeleton/facts/conventions/domains.md +2 -2
  18. package/dist/assets/templates/html/health.html +3 -5
  19. package/dist/cli/retired-commands.js +1 -1
  20. package/dist/cli/shared.js +6 -2
  21. package/dist/cli/unknown-flags.js +24 -1
  22. package/dist/cli.js +68 -10
  23. package/dist/commands/agent/agent-dispatch.js +1 -1
  24. package/dist/commands/command/command-execution.js +24 -62
  25. package/dist/commands/feedback-cli.js +0 -1
  26. package/dist/commands/health/accept-rate.js +6 -0
  27. package/dist/commands/health/archive-usage.js +92 -0
  28. package/dist/commands/health/checks.js +83 -74
  29. package/dist/commands/health/config-skew.js +38 -0
  30. package/dist/commands/health/data-dir-usage.js +25 -13
  31. package/dist/commands/health/egress.js +54 -0
  32. package/dist/commands/health/html-report.js +1 -42
  33. package/dist/commands/health/improve-metrics.js +136 -591
  34. package/dist/commands/health/md-report.js +1 -6
  35. package/dist/commands/health/plugin-staleness.js +53 -3
  36. package/dist/commands/health/renderers.js +12 -4
  37. package/dist/commands/health/report-view-model.js +14 -120
  38. package/dist/commands/health/types-improve.js +4 -19
  39. package/dist/commands/health/windows.js +64 -74
  40. package/dist/commands/health.js +145 -143
  41. package/dist/commands/improve/consolidate/chunking.js +26 -117
  42. package/dist/commands/improve/consolidate/continuity-check.js +137 -0
  43. package/dist/commands/improve/consolidate/pair-pass.js +791 -0
  44. package/dist/commands/improve/consolidate/sanitize.js +54 -149
  45. package/dist/commands/improve/consolidate.js +589 -1127
  46. package/dist/commands/improve/content-hash.js +16 -24
  47. package/dist/commands/improve/distill/content-repair.js +18 -100
  48. package/dist/commands/improve/distill-guards.js +20 -81
  49. package/dist/commands/improve/distill-promotion-policy.js +23 -243
  50. package/dist/commands/improve/distill.js +608 -1041
  51. package/dist/commands/improve/eligibility.js +126 -390
  52. package/dist/commands/improve/execution.js +8 -10
  53. package/dist/commands/improve/extract-prompt.js +1 -2
  54. package/dist/commands/improve/extract.js +487 -1046
  55. package/dist/commands/improve/feedback-valence.js +0 -25
  56. package/dist/commands/improve/improve-cli.js +75 -169
  57. package/dist/commands/improve/improve-result-file.js +10 -66
  58. package/dist/commands/improve/improve-strategies.js +52 -4
  59. package/dist/commands/improve/improve-usage-report.js +18 -64
  60. package/dist/commands/improve/improve.js +480 -1074
  61. package/dist/commands/improve/ledger.js +119 -0
  62. package/dist/commands/improve/locks.js +2 -8
  63. package/dist/commands/improve/loop-stages.js +415 -1073
  64. package/dist/commands/improve/memory/derived-ref.js +12 -77
  65. package/dist/commands/improve/memory/memory-belief.js +16 -118
  66. package/dist/commands/improve/memory/memory-improve.js +266 -14
  67. package/dist/commands/improve/outcome-loop.js +28 -156
  68. package/dist/commands/improve/planner.js +5 -15
  69. package/dist/commands/improve/preparation.js +779 -2319
  70. package/dist/commands/improve/proactive-maintenance.js +34 -101
  71. package/dist/commands/improve/reflect-noise.js +104 -280
  72. package/dist/commands/improve/reflect.js +642 -1353
  73. package/dist/commands/improve/retrieval-gate.js +127 -0
  74. package/dist/commands/improve/retrieval-scope.js +92 -0
  75. package/dist/commands/improve/salience.js +41 -240
  76. package/dist/commands/improve/session-asset.js +19 -100
  77. package/dist/commands/improve/stage.js +322 -0
  78. package/dist/commands/lint/base-linter.js +37 -15
  79. package/dist/commands/proposal/drain.js +261 -578
  80. package/dist/commands/proposal/proposal-cli.js +19 -20
  81. package/dist/commands/proposal/proposal-types.js +31 -24
  82. package/dist/commands/proposal/proposal.js +38 -8
  83. package/dist/commands/proposal/propose.js +134 -160
  84. package/dist/commands/proposal/repository.js +1097 -1394
  85. package/dist/commands/proposal/validators/proposal-quality-validators.js +71 -174
  86. package/dist/commands/proposal/validators/proposal-validators.js +1 -1
  87. package/dist/commands/proposal/validators/proposals.js +22 -89
  88. package/dist/commands/read/curate.js +105 -462
  89. package/dist/commands/read/knowledge.js +3 -2
  90. package/dist/commands/read/search-cli.js +16 -33
  91. package/dist/commands/read/search.js +17 -23
  92. package/dist/commands/read/show.js +57 -108
  93. package/dist/commands/sources/bundle-cli.js +25 -2
  94. package/dist/commands/sources/bundle-config-ops.js +4 -0
  95. package/dist/commands/sources/dangerous-env-audit.js +1 -2
  96. package/dist/commands/sources/info.js +127 -29
  97. package/dist/commands/sources/installed-stashes.js +197 -746
  98. package/dist/commands/sources/schema-repair.js +98 -129
  99. package/dist/commands/sources/source-add.js +62 -12
  100. package/dist/commands/sources/source-manage.js +9 -2
  101. package/dist/commands/sources/stash-cli.js +24 -4
  102. package/dist/commands/tasks/explain.js +10 -13
  103. package/dist/commands/tasks/tasks-cli.js +12 -13
  104. package/dist/commands/tasks/tasks.js +350 -936
  105. package/dist/commands/tasks/validate.js +26 -24
  106. package/dist/commands/workflow/plan.js +22 -29
  107. package/dist/commands/workflow-cli.js +4 -4
  108. package/dist/core/adapter/adapters/akm-adapter.js +2 -1
  109. package/dist/core/adapter/adapters/akm-lint.js +2 -3
  110. package/dist/core/adapter/adapters/akm-metadata.js +42 -12
  111. package/dist/core/adapter/adapters/akm-task-adapter.js +29 -8
  112. package/dist/core/adapter/adapters/akm-workflow-adapter.js +1 -1
  113. package/dist/core/adapter/execution-source.js +17 -29
  114. package/dist/core/asset/asset-placement.js +4 -13
  115. package/dist/core/asset/frontmatter.js +106 -1
  116. package/dist/core/asset/resolve-ref.js +1 -1
  117. package/dist/core/bundle-id.js +42 -5
  118. package/dist/core/bundle-rename.js +285 -0
  119. package/dist/core/config/config-io.js +1 -2
  120. package/dist/core/config/config-schema.js +9 -34
  121. package/dist/core/config/config-walker.js +1 -1
  122. package/dist/core/config/config.js +184 -111
  123. package/dist/core/config/engine-semantics.js +0 -2
  124. package/dist/core/config/legacy-source-shape-shim.js +38 -9
  125. package/dist/core/config/schema/embedding.js +20 -5
  126. package/dist/core/config/schema/engines.js +5 -0
  127. package/dist/core/config/schema/execution.js +1 -1
  128. package/dist/core/config/schema/experimental.js +1 -1
  129. package/dist/core/config/schema/improve-processes.js +54 -125
  130. package/dist/core/config/schema/improve.js +4 -42
  131. package/dist/core/config/schema/index-config.js +9 -48
  132. package/dist/core/config/schema/scheduler.js +12 -12
  133. package/dist/core/config/schema/search.js +6 -22
  134. package/dist/core/env-secret-ref.js +0 -1
  135. package/dist/core/errors.js +8 -9
  136. package/dist/core/file-change.js +13 -5
  137. package/dist/core/file-lock.js +76 -173
  138. package/dist/core/improve-result.js +35 -7
  139. package/dist/core/improve-types.js +0 -1
  140. package/dist/core/logs-db.js +2 -2
  141. package/dist/core/loopback.js +7 -12
  142. package/dist/core/non-task-input.js +20 -0
  143. package/dist/core/parse.js +13 -16
  144. package/dist/core/paths.js +0 -24
  145. package/dist/core/redaction.js +109 -2
  146. package/dist/core/run-lock.js +2 -5
  147. package/dist/core/spawn-env.js +1 -1
  148. package/dist/core/state/migrations.js +123 -61
  149. package/dist/core/state-db-scope.js +2 -4
  150. package/dist/core/state-db.js +126 -692
  151. package/dist/core/time.js +0 -20
  152. package/dist/core/type-presentation.js +1 -9
  153. package/dist/core/write-source.js +294 -1005
  154. package/dist/execution/input-contract.js +1 -1
  155. package/dist/execution/resolved-request.js +135 -689
  156. package/dist/execution/source.js +63 -257
  157. package/dist/execution/target-ref.js +1 -1
  158. package/dist/indexer/bundle-identity-guard.js +2 -2
  159. package/dist/indexer/db/llm-cache.js +2 -2
  160. package/dist/indexer/ensure-index.js +77 -73
  161. package/dist/indexer/index-rebuild-lock.js +3 -11
  162. package/dist/indexer/index-writer-lock.js +8 -17
  163. package/dist/indexer/index-written-assets.js +141 -154
  164. package/dist/indexer/indexer.js +400 -1124
  165. package/dist/indexer/links/declared-links.js +90 -0
  166. package/dist/indexer/materialize-embeddings.js +60 -397
  167. package/dist/indexer/passes/memory-inference.js +96 -90
  168. package/dist/indexer/passes/metadata.js +132 -219
  169. package/dist/indexer/read-preflight.js +0 -7
  170. package/dist/indexer/scan/doc-to-entry.js +2 -3
  171. package/dist/indexer/scan/drain-dir.js +1 -1
  172. package/dist/indexer/search/db-search.js +190 -590
  173. package/dist/indexer/search/fts-query.js +30 -41
  174. package/dist/indexer/search/ranking.js +28 -154
  175. package/dist/indexer/search/search-attribution.js +12 -32
  176. package/dist/indexer/search/search-fields.js +11 -15
  177. package/dist/indexer/search/search-hit-enrichers.js +54 -85
  178. package/dist/indexer/search/search-source.js +1 -4
  179. package/dist/indexer/usage/usage-events.js +36 -7
  180. package/dist/indexer/walk/walker.js +3 -4
  181. package/dist/integrations/agent/engine-fallback.js +23 -40
  182. package/dist/integrations/agent/engine-resolution.js +93 -183
  183. package/dist/integrations/agent/execution.js +507 -0
  184. package/dist/integrations/agent/model-map.js +28 -156
  185. package/dist/integrations/agent/request-lowering.js +66 -141
  186. package/dist/integrations/agent/runner-dispatch.js +143 -321
  187. package/dist/integrations/agent/runner.js +54 -14
  188. package/dist/integrations/lockfile.js +53 -101
  189. package/dist/llm/client.js +18 -6
  190. package/dist/llm/embedders/deterministic.js +2 -3
  191. package/dist/llm/embedders/profile.js +71 -0
  192. package/dist/llm/embedders/remote.js +11 -17
  193. package/dist/llm/feature-gate.js +0 -8
  194. package/dist/llm/index-passes.js +3 -5
  195. package/dist/llm/memory-infer.js +1 -2
  196. package/dist/llm/structured-call.js +5 -24
  197. package/dist/output/generic-render.js +23 -11
  198. package/dist/output/html-render.js +13 -10
  199. package/dist/output/render-registry.js +3 -32
  200. package/dist/output/shapes/helpers.js +25 -38
  201. package/dist/output/shapes/passthrough.js +1 -9
  202. package/dist/{indexer/graph/graph-types.js → output/text/bundle-rename.js} +4 -1
  203. package/dist/output/text/command-format.js +69 -31
  204. package/dist/output/text/helpers.js +1 -1
  205. package/dist/output/text/migrate.js +5 -14
  206. package/dist/output/text/proposal-format.js +48 -3
  207. package/dist/output/text/show-format.js +13 -17
  208. package/dist/output/text/workflow-format.js +0 -32
  209. package/dist/output/text.js +2 -0
  210. package/dist/registry/factory.js +4 -19
  211. package/dist/registry/network.js +66 -220
  212. package/dist/registry/providers/index.js +0 -2
  213. package/dist/registry/providers/skills-sh.js +3 -14
  214. package/dist/registry/providers/static-index.js +24 -26
  215. package/dist/registry/resolve.js +55 -131
  216. package/dist/scripts/akm-migrate-node.js +42948 -92369
  217. package/dist/scripts/akm-migrate.js +42935 -92354
  218. package/dist/setup/registry-stash-loader.js +4 -13
  219. package/dist/setup/semantic-assets.js +3 -44
  220. package/dist/setup/setup.js +1 -1
  221. package/dist/setup/steps/connection.js +5 -6
  222. package/dist/setup/steps/platforms.js +2 -2
  223. package/dist/setup/steps/tasks.js +25 -15
  224. package/dist/sources/provider-factory.js +17 -18
  225. package/dist/sources/providers/filesystem.js +2 -3
  226. package/dist/sources/providers/git-install.js +7 -1
  227. package/dist/sources/providers/git-provider.js +0 -3
  228. package/dist/sources/providers/git-stash.js +83 -21
  229. package/dist/sources/providers/npm.js +2 -4
  230. package/dist/sources/providers/provider-utils.js +5 -10
  231. package/dist/sources/providers/website.js +0 -2
  232. package/dist/sources/snapshot-fetchers/website-ingest.js +1 -1
  233. package/dist/sources/website-url.js +2 -2
  234. package/dist/storage/database.js +9 -35
  235. package/dist/storage/repositories/improve-ledger-repository.js +209 -0
  236. package/dist/storage/repositories/index-connection.js +39 -72
  237. package/dist/storage/repositories/index-entries-repository.js +131 -129
  238. package/dist/storage/repositories/index-entry-mapper.js +1 -2
  239. package/dist/storage/repositories/index-entry-schema.js +101 -268
  240. package/dist/storage/repositories/index-fts-repository.js +86 -256
  241. package/dist/storage/repositories/index-links-repository.js +143 -0
  242. package/dist/storage/repositories/index-llm-cache-repository.js +7 -9
  243. package/dist/storage/repositories/index-meta-repository.js +6 -4
  244. package/dist/storage/repositories/index-schema.js +257 -325
  245. package/dist/storage/repositories/index-utility-repository.js +8 -29
  246. package/dist/storage/repositories/index-vec-repository.js +133 -414
  247. package/dist/storage/repositories/outcome-repository.js +2 -1
  248. package/dist/storage/repositories/proposals-repository.js +104 -1
  249. package/dist/storage/repositories/registry-index-cache-repository.js +100 -0
  250. package/dist/storage/repositories/salience-repository.js +1 -19
  251. package/dist/storage/repositories/task-history-repository.js +26 -4
  252. package/dist/storage/repositories/workflow-runs-repository.js +53 -244
  253. package/dist/storage/sqlite-migrations.js +136 -0
  254. package/dist/storage/sqlite-pragmas.js +11 -9
  255. package/dist/storage/sqlite-transaction.js +170 -0
  256. package/dist/storage/state-db-integrity.js +130 -0
  257. package/dist/tasks/activation-config.js +134 -62
  258. package/dist/tasks/backends/cron.js +191 -302
  259. package/dist/tasks/backends/exec-utils.js +2 -5
  260. package/dist/tasks/backends/launchd.js +141 -748
  261. package/dist/tasks/backends/schtasks.js +119 -623
  262. package/dist/tasks/prepare/prepare-support.js +5 -15
  263. package/dist/tasks/prepare/prepare.js +0 -2
  264. package/dist/tasks/resolve-akm-bin.js +20 -79
  265. package/dist/tasks/run/attempt-lifecycle.js +0 -1
  266. package/dist/tasks/run/load-task.js +1 -1
  267. package/dist/tasks/scheduler-binding.js +20 -238
  268. package/dist/tasks/scheduler-invocation.js +136 -244
  269. package/dist/tasks/scheduler-lock.js +53 -0
  270. package/dist/tasks/scheduler-sync.js +368 -679
  271. package/dist/tasks/source/parse-task-source.js +55 -9
  272. package/dist/tasks/source/task-source-v3-frozen.js +3 -4
  273. package/dist/tasks/source/task-to-v4.js +464 -88
  274. package/dist/workflows/authoring/authoring.js +3 -12
  275. package/dist/workflows/compile.js +211 -0
  276. package/dist/workflows/concurrency-policy.js +13 -74
  277. package/dist/workflows/exec/child-invocation.js +3 -17
  278. package/dist/workflows/exec/child-workflow.js +32 -141
  279. package/dist/workflows/exec/dispatch-redaction.js +13 -53
  280. package/dist/workflows/exec/environment.js +98 -0
  281. package/dist/workflows/exec/exec-unit.js +33 -140
  282. package/dist/workflows/exec/frozen-judge.js +7 -59
  283. package/dist/workflows/exec/native-executor.js +82 -341
  284. package/dist/workflows/exec/param-secrets.js +29 -47
  285. package/dist/workflows/exec/run-workflow.js +154 -387
  286. package/dist/workflows/exec/scheduler.js +9 -36
  287. package/dist/workflows/exec/step-work.js +127 -430
  288. package/dist/workflows/exec/unit-dispatch.js +11 -63
  289. package/dist/workflows/exec/unit-writer.js +8 -52
  290. package/dist/workflows/exec/worktree.js +39 -273
  291. package/dist/workflows/freeze/child-output-references.js +4 -15
  292. package/dist/workflows/freeze/environment.js +99 -92
  293. package/dist/workflows/freeze/freeze.js +172 -0
  294. package/dist/workflows/freeze/step-values.js +19 -21
  295. package/dist/workflows/freeze/targets/child-workflow.js +23 -92
  296. package/dist/workflows/freeze/targets/command.js +10 -33
  297. package/dist/workflows/freeze/targets/script.js +5 -12
  298. package/dist/workflows/freeze/targets/shell.js +3 -6
  299. package/dist/workflows/freeze/targets/task.js +25 -80
  300. package/dist/workflows/freeze/task-bindings.js +20 -67
  301. package/dist/workflows/{source-ir/github-yaml.js → github-yaml.js} +88 -206
  302. package/dist/workflows/ir/params.js +6 -51
  303. package/dist/workflows/ir/plan-hash.js +2 -34
  304. package/dist/workflows/parser.js +140 -43
  305. package/dist/{commands/improve/consolidate/types.js → workflows/plan.js} +2 -1
  306. package/dist/workflows/renderer.js +36 -69
  307. package/dist/workflows/resource-limits.js +12 -120
  308. package/dist/workflows/runtime/agent-identity.js +8 -40
  309. package/dist/workflows/runtime/run-outputs.js +3 -6
  310. package/dist/workflows/runtime/run-plan.js +316 -0
  311. package/dist/workflows/runtime/runs.js +48 -200
  312. package/dist/workflows/runtime/workflow-asset-loader.js +24 -57
  313. package/dist/workflows/{source-ir/semantics.js → source-semantics.js} +16 -20
  314. package/dist/workflows/validate-summary.js +2 -7
  315. package/docs/integration/bundling-akm.md +49 -42
  316. package/docs/migration/README.md +1 -0
  317. package/docs/migration/release-notes/0.9.17.md +43 -0
  318. package/docs/migration/v0.9.1-to-v0.9.2.md +23 -7
  319. package/docs/reference/cli.md +232 -135
  320. package/docs/reference/configuration.md +71 -57
  321. package/docs/reference/data-and-telemetry.md +20 -21
  322. package/docs/reference/tasks.md +105 -39
  323. package/docs/reference/workflow-schema.md +14 -18
  324. package/docs/reference/workflows.md +6 -9
  325. package/package.json +1 -1
  326. package/schemas/akm-config.json +115 -738
  327. package/schemas/akm-workflow.json +1 -0
  328. package/dist/assets/improve-strategies/graph-refresh.json +0 -15
  329. package/dist/assets/prompts/contradiction-judge.md +0 -33
  330. package/dist/assets/prompts/graph-extract-system.md +0 -1
  331. package/dist/assets/prompts/graph-extract-user-prompt.md +0 -35
  332. package/dist/assets/prompts/metadata-enhance-system.md +0 -1
  333. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +0 -4
  334. package/dist/commands/health/advisories.js +0 -150
  335. package/dist/commands/health/metrics.js +0 -329
  336. package/dist/commands/health/surfaces.js +0 -102
  337. package/dist/commands/improve/anti-collapse.js +0 -83
  338. package/dist/commands/improve/collapse-detector.js +0 -432
  339. package/dist/commands/improve/consolidate/eligibility.js +0 -48
  340. package/dist/commands/improve/consolidate/merge.js +0 -149
  341. package/dist/commands/improve/distill/promote-memory.js +0 -291
  342. package/dist/commands/improve/distill/quality-gate.js +0 -337
  343. package/dist/commands/improve/eval-cases.js +0 -52
  344. package/dist/commands/improve/memory/memory-contradiction-detect.js +0 -291
  345. package/dist/commands/improve/proposal-envelope.js +0 -31
  346. package/dist/commands/improve/run-context.js +0 -123
  347. package/dist/commands/improve/shared.js +0 -31
  348. package/dist/commands/improve/source-identity.js +0 -28
  349. package/dist/commands/improve/triage.js +0 -96
  350. package/dist/commands/proposal/drain-policies.js +0 -151
  351. package/dist/commands/sources/update-transaction.js +0 -220
  352. package/dist/core/action-contributors.js +0 -28
  353. package/dist/core/config/config-version-shim.js +0 -101
  354. package/dist/core/fs-txn.js +0 -405
  355. package/dist/core/lexical-score.js +0 -25
  356. package/dist/core/maintenance-barrier.js +0 -167
  357. package/dist/execution/executable-identity.js +0 -105
  358. package/dist/execution/guarded-source.js +0 -427
  359. package/dist/indexer/db/graph-db.js +0 -444
  360. package/dist/indexer/graph/graph-boost.js +0 -427
  361. package/dist/indexer/graph/graph-dedup.js +0 -95
  362. package/dist/indexer/graph/graph-extraction.js +0 -1108
  363. package/dist/indexer/search/name-match.js +0 -35
  364. package/dist/indexer/search/ranking-contributors.js +0 -515
  365. package/dist/indexer/search/ranking-types.js +0 -4
  366. package/dist/indexer/walk/project-context.js +0 -192
  367. package/dist/integrations/agent/execution-cascade.js +0 -566
  368. package/dist/integrations/agent/execution-definitions.js +0 -202
  369. package/dist/integrations/agent/execution-lowering.js +0 -841
  370. package/dist/integrations/agent/execution-preparation.js +0 -98
  371. package/dist/integrations/agent/inline-execution.js +0 -74
  372. package/dist/llm/graph-extract.js +0 -728
  373. package/dist/llm/metadata-enhance.js +0 -96
  374. package/dist/registry/create-provider-registry.js +0 -29
  375. package/dist/registry/pinned-request-helper.js +0 -247
  376. package/dist/registry/pinned-transport.js +0 -717
  377. package/dist/sources/providers/index.js +0 -14
  378. package/dist/storage/engines/sqlite-migrations.js +0 -271
  379. package/dist/storage/repositories/canaries-repository.js +0 -107
  380. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
  381. package/dist/storage/repositories/registry-cache.js +0 -113
  382. package/dist/tasks/scheduler-sync-preview.js +0 -52
  383. package/dist/tasks/source/task-to-v3.js +0 -507
  384. package/dist/workflows/freeze/resolve-steps.js +0 -86
  385. package/dist/workflows/freeze/source-freeze.js +0 -64
  386. package/dist/workflows/ir/compile.js +0 -321
  387. package/dist/workflows/ir/environment-v4.js +0 -330
  388. package/dist/workflows/ir/freeze-v4.js +0 -153
  389. package/dist/workflows/ir/schema-v4.js +0 -745
  390. package/dist/workflows/ir/schema.js +0 -354
  391. package/dist/workflows/program/schema.js +0 -77
  392. package/dist/workflows/runtime/checkin.js +0 -57
  393. package/dist/workflows/runtime/plan-classifier.js +0 -196
  394. package/dist/workflows/runtime/unit-checkin.js +0 -45
  395. package/dist/workflows/runtime/unit-phases.js +0 -20
  396. package/dist/workflows/schema.js +0 -4
  397. package/dist/workflows/source-ir/compile.js +0 -200
  398. package/dist/workflows/source-ir/program.js +0 -50
  399. package/dist/workflows/source-ir/result.js +0 -26
  400. package/dist/workflows/source-ir/schema.js +0 -786
  401. package/dist/workflows/source-ir/triggers.js +0 -79
  402. package/dist/workflows/source-ir/uses.js +0 -40
  403. package/dist/workflows/validator.js +0 -60
@@ -7,8 +7,13 @@
7
7
  * Each handler exported here is a pure function that performs the real work;
8
8
  * `src/cli.ts` wraps these in citty `defineCommand`s and shapes their return
9
9
  * values via `output()`.
10
+ *
11
+ * Every command that writes the native scheduler (`add`, `enable`,
12
+ * `disable`, `sync`, `prune --yes`) runs under the one scheduler lock
13
+ * (`withSchedulerLock`): read the installed rows once, plan against the
14
+ * sources, then install, update, or remove row by row. One row that fails is
15
+ * reported and the rest still apply.
10
16
  */
11
- import { createHash } from "node:crypto";
12
17
  import fs from "node:fs";
13
18
  import path from "node:path";
14
19
  import { stringify as yamlStringify } from "yaml";
@@ -17,28 +22,28 @@ import { assetPathForName } from "../../core/asset/asset-placement.js";
17
22
  import { makeBundleRef, parseBundleRef } from "../../core/asset/asset-ref.js";
18
23
  import { conceptIdFromTypeName, isFullRefInput } from "../../core/asset/resolve-ref.js";
19
24
  import { isWithin, resolveStashDir } from "../../core/common.js";
20
- import { loadConfig } from "../../core/config/config.js";
21
- import { bundleComponentConfig, bundleKeyForContentRoot, resolveActiveConfiguredSources, resolveConfiguredSources, } from "../../core/config/config-sources.js";
25
+ import { loadConfig, mutateConfig, resetConfigCache } from "../../core/config/config.js";
26
+ import { bundleKeyForContentRoot, resolveActiveConfiguredSources, resolveConfiguredSources, } from "../../core/config/config-sources.js";
22
27
  import { IMPROVE_AUTONOMY_CONFIG_KEY, isImproveAutonomyEnabled } from "../../core/config/experimental.js";
23
28
  import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
24
29
  import { getTaskHistoryDir, getTaskLogDir } from "../../core/paths.js";
25
30
  import { warn } from "../../core/warn.js";
26
- import { commitWriteTargetBoundary, deleteAssetFromSource, prepareWriteTargetForMutation, resolveWorkingStashTarget, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
31
+ import { commitWriteTargetBoundary, isWriteCapableSourceKind, prepareWriteTargetForMutation, resolveWorkingStashTarget, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
27
32
  import { withEngineFallback } from "../../integrations/agent/engine-fallback.js";
28
33
  import { resolveAssetPath } from "../../sources/resolve.js";
29
- import { activeSchedulerActivations, isSchedulerRefEnabled, setSchedulerRefEnabled, } from "../../tasks/activation-config.js";
34
+ import { enabledRefsFromInstalled, schedulerEnabledRefs, setSchedulerRefEnabled } from "../../tasks/activation-config.js";
30
35
  import { backendNameForPlatform, selectBackend } from "../../tasks/backends/index.js";
31
36
  import { prepareTaskV3Execution } from "../../tasks/prepare/prepare.js";
32
- import { resolveAkmInvocation } from "../../tasks/resolve-akm-bin.js";
37
+ import { isCheckoutInvocation, resolveAkmInvocation } from "../../tasks/resolve-akm-bin.js";
33
38
  import { createExecutionProvenanceContext } from "../../tasks/run/provenance.js";
34
39
  import { runTask } from "../../tasks/run/run-task.js";
35
40
  import { readTaskHistory } from "../../tasks/run/task-history.js";
36
41
  import { exitCodeForStatus } from "../../tasks/run/task-result.js";
37
42
  import { parseSchedule, SCHEDULE_SUPPORTED_SUBSET_HINT } from "../../tasks/schedule.js";
38
- import { assertSchedulerMutationArtifact, assertSchedulerNativeArtifactCardinality, compileTaskSchedulerBindings, schedulerBindingNativeId, schedulerBindingOrdinal, schedulerNativeArtifactKey, schedulerNativeBindingId, } from "../../tasks/scheduler-binding.js";
39
- import { schedulerContextDescriptor, schedulerContextPath, validateSchedulerContextDescriptor, writeSchedulerContextDescriptor, } from "../../tasks/scheduler-invocation.js";
40
- import { assertSchedulerBackendInspection, assertSchedulerNativeArtifactOwnership, assertSchedulerSourceSnapshot, buildSchedulerRemoveOperation, finalizeSchedulerSyncPlan, prepareSchedulerSyncSourceSet, } from "../../tasks/scheduler-sync.js";
41
- import { renderSchedulerPlanPreview, renderSchedulerSyncPlanPreview, } from "../../tasks/scheduler-sync-preview.js";
43
+ import { compileTaskSchedulerBindings, schedulerBindingNativeId, } from "../../tasks/scheduler-binding.js";
44
+ import { readLegacySchedulerContext, scheduledRowEnvironment } from "../../tasks/scheduler-invocation.js";
45
+ import { withSchedulerLock } from "../../tasks/scheduler-lock.js";
46
+ import { compileSchedulerSources, installedRowNativeId, installedRowOwner, installedRowScope, planSchedulerSync, renderSchedulerPlanPreview, scheduledInvocationBundle, } from "../../tasks/scheduler-sync.js";
42
47
  import { parseTaskSource } from "../../tasks/source/parse-task-source.js";
43
48
  import { projectTaskSourceV4 } from "../../tasks/source/project-v4.js";
44
49
  import { normaliseTaskConceptId, normaliseTaskId } from "../../tasks/task-id.js";
@@ -47,27 +52,17 @@ import { resolveImproveStrategy } from "../improve/improve-strategies.js";
47
52
  export async function akmTasksAdd(input, deps = {}) {
48
53
  const id = normaliseTaskId(input.id);
49
54
  assertTaskAddTargetShape(input);
50
- // Validate the schedule for the active backend before writing anything.
51
- // WI-9.10e: the injected backend (tests) carries its own name, so derive it
52
- // from `deps.backend` when present — retiring the `_setBackendsForTests` seam.
55
+ // Validate the schedule for the active backend before writing anything. An
56
+ // injected backend (tests) carries its own name.
53
57
  const backend = deps.backend?.name ?? backendNameForPlatform();
54
58
  parseSchedule(input.schedule, backend);
55
- const bundle = resolveTaskBundle(input.target, { requireWritable: true });
56
- const writeTarget = bundle.resolved;
59
+ const bundle = resolveTaskBundle(input.target);
57
60
  const stashDir = bundle.stashDir;
58
- const installOpts = bundle.installTarget !== undefined ? { target: bundle.installTarget } : undefined;
59
61
  const typeRoot = path.join(stashDir, "tasks");
60
62
  const assetPath = assetPathForName("task", typeRoot, id);
61
63
  if (!isWithin(assetPath, typeRoot)) {
62
64
  throw new UsageError(`Resolved task path escapes the stash: "${id}".`, "PATH_ESCAPE_VIOLATION");
63
65
  }
64
- if (fs.existsSync(assetPath) && !input.force) {
65
- throw new UsageError(`Task "${id}" already exists. Pass --force to overwrite, or delete its file and run \`akm task sync\` first.`, "RESOURCE_ALREADY_EXISTS");
66
- }
67
- const sourceExpectation = captureTaskSourceExpectation(assetPath, stashDir);
68
- if (sourceExpectation.state === "present" && !input.force) {
69
- throw new UsageError(`Task "${id}" appeared while add was preparing. Pass --force only after reviewing the current owner.`, "RESOURCE_ALREADY_EXISTS");
70
- }
71
66
  const yaml = renderTaskYaml({
72
67
  id,
73
68
  schedule: input.schedule,
@@ -94,12 +89,9 @@ export async function akmTasksAdd(input, deps = {}) {
94
89
  config: bundle.config,
95
90
  resolveAsset: taskProjectionAssetResolver(bundle.config, bundle.bundleName, stashDir),
96
91
  });
97
- // Bindings are compiled from the original parsed document so each
98
- // schedule entry's inputs reach the generated invocation.
99
- const taskBindings = compileTaskSchedulerBindings({
92
+ const bindings = compileTaskSchedulerBindings({
100
93
  id,
101
94
  qualifiedRef,
102
- ...(bundle.installTarget ? { bundleTarget: bundle.installTarget } : {}),
103
95
  schedules: parsedTask.v4.schedule.map((schedule) => ({
104
96
  cron: schedule.cron,
105
97
  ordinal: schedule.ordinal,
@@ -107,136 +99,49 @@ export async function akmTasksAdd(input, deps = {}) {
107
99
  inputs: schedule.inputs,
108
100
  })),
109
101
  });
110
- const taskBinding = taskBindings[0];
111
- if (!taskBinding)
102
+ const first = bindings[0];
103
+ if (!first)
112
104
  throw new UsageError(`Task "${id}" has no schedulable trigger.`, "INVALID_FLAG_VALUE");
113
- const ref = taskAssetRef(id);
114
105
  const sched = deps.backend ?? selectBackend();
115
- const writeAsset = deps.writeAsset ?? writeAssetToSource;
116
- const deleteAsset = deps.deleteAsset ?? deleteAssetFromSource;
117
- const commitBoundary = deps.commitBoundary ?? commitWriteTargetBoundary;
118
- if (input.disabled) {
119
- // Revoke before publishing replacement bytes. If a write/commit/sync
120
- // fails, the safe partial state is an inert task, never an activated new
121
- // source (or a stale native binding that can still dispatch it).
122
- setSchedulerRefEnabled("task", qualifiedRef, false);
123
- await writeAsset(writeTarget.source, writeTarget.config, ref, yaml);
124
- commitBoundary(writeTarget, `Update tasks/${id}`);
125
- await akmTasksSync(deps, bundle.bundleName);
106
+ return withSchedulerLock(async () => {
107
+ if (fs.existsSync(assetPath) && !input.force) {
108
+ throw new UsageError(`Task "${id}" already exists. Pass --force to overwrite, or delete its file and run \`akm task sync\` first.`, "RESOURCE_ALREADY_EXISTS");
109
+ }
110
+ // Native ids are shared by every bundle and installation: refuse before
111
+ // writing anything when another one already schedules this id.
112
+ const scope = bundleScope(bundle.bundleName, stashDir);
113
+ const installed = await listInstalledRows(sched);
114
+ for (const binding of bindings) {
115
+ const row = installed.find((candidate) => installedRowNativeId(candidate) === schedulerBindingNativeId(binding));
116
+ if (row && !installedRowScope(row, [scope])) {
117
+ throw new UsageError(`Task id "${id}" is already scheduled from ${installedRowOwner(row)}; rename the task or disable the existing one first.`, "RESOURCE_ALREADY_EXISTS");
118
+ }
119
+ }
120
+ await ensureSchedulerChoice({ backend: sched });
121
+ if (input.disabled)
122
+ setSchedulerRefEnabled(qualifiedRef, false);
123
+ await (deps.writeAsset ?? writeAssetToSource)(bundle.resolved.source, bundle.resolved.config, taskAssetRef(id), yaml);
124
+ (deps.commitBoundary ?? commitWriteTargetBoundary)(bundle.resolved, `Update tasks/${id}`);
125
+ if (!input.disabled)
126
+ setSchedulerRefEnabled(qualifiedRef, true);
127
+ const sync = await akmTasksSync(deps, bundle.bundleName, { rebind: input.rebind === true });
128
+ for (const warning of sync.warnings ?? [])
129
+ warn(warning);
130
+ const failure = sync.failures.find((candidate) => candidate.ref === qualifiedRef);
131
+ if (failure) {
132
+ throw new ConfigError(`Task "${id}" was written to ${assetPath}${input.disabled ? "" : " and enabled"}, but it could not be scheduled: ${failure.reason}`, "INVALID_CONFIG_FILE", "Fix the cause and run `akm task sync`.");
133
+ }
126
134
  return {
127
135
  id,
128
136
  ref: conceptIdFromTypeName("task", id),
129
137
  path: assetPath,
130
138
  bundleDir: stashDir,
131
- schedule: taskBinding.cron,
132
- enabled: false,
139
+ schedule: first.cron,
140
+ enabled: input.disabled !== true,
133
141
  backend,
134
142
  target: task.target,
135
143
  };
136
- }
137
- const transaction = await prepareTaskAddSchedulerTransaction({
138
- id,
139
- installTarget: bundle.installTarget,
140
- ownerTarget: bundle.bundleName,
141
- installOpts,
142
- taskBindings,
143
- sched,
144
- deps,
145
- rebind: input.rebind === true,
146
- });
147
- let sourceMutationReceipt;
148
- let sourcePublished = false;
149
- let sourcePublicationAttempted = false;
150
- const publishSource = async () => {
151
- assertTaskSourceExpectation(sourceExpectation);
152
- sourcePublicationAttempted = true;
153
- await writeAsset(writeTarget.source, writeTarget.config, ref, yaml);
154
- const publishedSource = captureTaskSourceExpectation(assetPath, stashDir);
155
- if (publishedSource.state !== "present" || publishedSource.sha256 !== hashTaskSource(yaml)) {
156
- throw new UsageError(`Task source ${JSON.stringify(assetPath)} changed during publication.`, "RESOURCE_ALREADY_EXISTS");
157
- }
158
- sourceMutationReceipt = publishedSource;
159
- sourcePublished = true;
160
- transaction.publishRuntime?.();
161
- };
162
- await applySchedulerTransaction(sched, transaction.operations, {
163
- initialExpectations: transaction.initialExpectations,
164
- assertReadSet: () => {
165
- if (sourcePublished) {
166
- if (!sourceMutationReceipt) {
167
- throw new ConfigError("Published task source lost its transaction receipt.", "INVALID_CONFIG_FILE");
168
- }
169
- assertTaskSourceExpectation(sourceMutationReceipt);
170
- }
171
- else {
172
- assertTaskSourceExpectation(sourceExpectation);
173
- }
174
- },
175
- beforeOperation: async (_operation, index) => {
176
- if (index === transaction.publishOperationIndex && !sourcePublished)
177
- await publishSource();
178
- },
179
- afterOperations: () => commitBoundary(writeTarget, `Update tasks/${id}`),
180
- rollbackExternal: async () => {
181
- if (!sourcePublicationAttempted)
182
- return;
183
- if (!sourceMutationReceipt) {
184
- const current = captureTaskSourceExpectation(assetPath, stashDir);
185
- if (sameTaskSourceExpectation(current, sourceExpectation))
186
- return;
187
- if (current.state !== "present" || current.sha256 !== hashTaskSource(yaml)) {
188
- throw new UsageError(`Task source ${JSON.stringify(assetPath)} has an unowned publication state; refusing rollback over a possible concurrent owner.`, "RESOURCE_ALREADY_EXISTS");
189
- }
190
- sourceMutationReceipt = current;
191
- }
192
- assertTaskSourceExpectation(sourceMutationReceipt);
193
- try {
194
- if (sourceExpectation.state === "absent") {
195
- await deleteAsset(writeTarget.source, writeTarget.config, ref);
196
- }
197
- else {
198
- await writeAsset(writeTarget.source, writeTarget.config, ref, sourceExpectation.content);
199
- const providerRestored = captureTaskSourceExpectation(assetPath, stashDir);
200
- if (providerRestored.state !== "present" || providerRestored.sha256 !== sourceExpectation.sha256) {
201
- // Provider writers conventionally normalize a trailing newline.
202
- // Rollback is byte-exact, so finish the already-owned restore with
203
- // the frozen bytes before checking the physical source state.
204
- fs.writeFileSync(assetPath, Buffer.from(sourceExpectation.bytesBase64, "base64"));
205
- }
206
- }
207
- assertTaskSourceRestored(sourceExpectation);
208
- commitBoundary(writeTarget, `Restore tasks/${id}`);
209
- }
210
- catch (cause) {
211
- // A write/commit seam may report failure after it has already restored
212
- // the exact source bytes. Prove that state before allowing native
213
- // rollback; otherwise preserve the possible concurrent source owner.
214
- try {
215
- assertTaskSourceRestored(sourceExpectation);
216
- }
217
- catch {
218
- throw cause;
219
- }
220
- throw new TaskSourceRestoredBoundaryError(cause);
221
- }
222
- },
223
- suppressNativeRollbackWhenExternalFails: true,
224
- allowNativeRollbackAfterExternalFailure: (error) => error instanceof TaskSourceRestoredBoundaryError,
225
144
  });
226
- // Publish the host-local grant only after source and native state commit.
227
- // A failure before this point therefore leaves any new binding inert at
228
- // its scheduled-fire gate instead of authorizing partially committed code.
229
- setSchedulerRefEnabled("task", qualifiedRef, true);
230
- return {
231
- id,
232
- ref: conceptIdFromTypeName("task", id),
233
- path: assetPath,
234
- bundleDir: stashDir,
235
- schedule: taskBinding.cron,
236
- enabled: true,
237
- backend,
238
- target: task.target,
239
- };
240
145
  }
241
146
  function assertTaskAddTargetShape(input) {
242
147
  const hasCommand = input.command !== undefined &&
@@ -264,11 +169,6 @@ export async function akmTasksRun(id, options = {}) {
264
169
  const adapterId = bundle.source.adapterId ?? detectAdapterId(bundle.source.path);
265
170
  const resolvedId = taskIdForAdapter(parsed.id, adapterId);
266
171
  const scheduled = options.scheduled === true;
267
- const conceptId = adapterId === "akm" ? `tasks/${resolvedId}` : resolvedId;
268
- const qualifiedRef = makeBundleRef(bundle.source.name, conceptId);
269
- if (scheduled && !isSchedulerRefEnabled(loadConfig(), "task", qualifiedRef)) {
270
- throw new UsageError(`Scheduled task ${JSON.stringify(qualifiedRef)} is not enabled in local scheduler config; run \`akm task enable ${qualifiedRef}\`.`, "INVALID_FLAG_VALUE");
271
- }
272
172
  // D5 "Construction" (spec docs/plans/specs/p1b-model-extraction.md §1.2/
273
173
  // §5.2): built ONCE at this invocation boundary. eventSource is "task"
274
174
  // whether or not --scheduled was passed (§1.6 D5-N1) — scheduled stays a
@@ -342,17 +242,41 @@ function resolveTaskActivation(ref, target, requireSchedulable) {
342
242
  }
343
243
  return { qualifiedRef: makeBundleRef(bundle.source.name, conceptId), sourcePath, bundleName: bundle.source.name };
344
244
  }
245
+ /**
246
+ * A config that predates `scheduler.enabled` means "keep what is installed":
247
+ * write that as the host's explicit list before one ref is added or removed,
248
+ * so enabling or disabling a single task never silently drops the others.
249
+ */
250
+ async function ensureSchedulerChoice(deps) {
251
+ const config = loadConfig();
252
+ if (schedulerEnabledRefs(config) !== undefined)
253
+ return;
254
+ initializeSchedulerChoice(await (deps.backend ?? selectBackend()).list(), config, { write: true });
255
+ }
256
+ function initializeSchedulerChoice(installed, config, options) {
257
+ const refs = enabledRefsFromInstalled(installed, config);
258
+ if (options.write) {
259
+ mutateConfig((current) => ({ ...current, scheduler: { ...current.scheduler, enabled: [...refs] } }));
260
+ resetConfigCache();
261
+ }
262
+ warn(`This host had no scheduler.enabled list; ${options.write ? "it was" : "it would be"} initialized from the ` +
263
+ `${refs.length} installed akm scheduler ${refs.length === 1 ? "binding" : "bindings"}` +
264
+ `${refs.length > 0 ? `: ${refs.join(", ")}` : ""}. Use \`akm task enable\`/\`akm task disable <ref>\` to change it.`);
265
+ return refs;
266
+ }
345
267
  export async function akmTasksEnable(ref, options = {}, deps = {}) {
346
- const resolved = resolveTaskActivation(ref, options.target, true);
347
- const activation = setSchedulerRefEnabled("task", resolved.qualifiedRef, true);
348
- const sync = await akmTasksSync(deps, resolved.bundleName);
349
- return { ref: resolved.qualifiedRef, enabled: true, changed: activation.changed, sync };
268
+ return setTaskActivation(resolveTaskActivation(ref, options.target, true), true, deps);
350
269
  }
351
270
  export async function akmTasksDisable(ref, options = {}, deps = {}) {
352
- const resolved = resolveTaskActivation(ref, options.target, false);
353
- const activation = setSchedulerRefEnabled("task", resolved.qualifiedRef, false);
354
- const sync = await akmTasksSync(deps, resolved.bundleName);
355
- return { ref: resolved.qualifiedRef, enabled: false, changed: activation.changed, sync };
271
+ return setTaskActivation(resolveTaskActivation(ref, options.target, false), false, deps);
272
+ }
273
+ async function setTaskActivation(resolved, enabled, deps) {
274
+ return withSchedulerLock(async () => {
275
+ await ensureSchedulerChoice(deps);
276
+ const activation = setSchedulerRefEnabled(resolved.qualifiedRef, enabled);
277
+ const sync = await akmTasksSync(deps, resolved.bundleName);
278
+ return { ref: resolved.qualifiedRef, enabled, changed: activation.changed, sync };
279
+ });
356
280
  }
357
281
  export async function akmTasksHistory(input) {
358
282
  const limit = input.limit !== undefined && input.limit > 0 ? input.limit : 50;
@@ -365,340 +289,288 @@ export async function akmTasksHistory(input) {
365
289
  }
366
290
  /**
367
291
  * Reconcile host-local scheduler activation with authored tasks/workflows.
368
- * • without --bundle, scan every enabled configured bundle in one transaction
369
- * • with --bundle, reconcile only that bundle
370
- * • only refs present in scheduler.enabled enter the desired set
371
- * • install missing bindings only after that whole-set preflight succeeds
372
- * • reinstall bindings whose authored schedule or runtime state changed
373
- * (drift detected by comparing the backend's installed signature against
374
- * the signature the current definition would produce)
375
- * • remove orphan scheduler entries that no longer have a backing file
292
+ *
293
+ * • without a bundle, every enabled configured filesystem/git bundle; with
294
+ * one, only that bundle
295
+ * • only refs listed in `scheduler.enabled` are read and scheduled
296
+ * • a row is installed when missing, rewritten when its rendered definition
297
+ * differs, and removed when its source is gone or no longer enabled
298
+ * • rows another bundle or installation owns are never touched, and a row
299
+ * whose source fails to compile is left as it is
300
+ * • a disabled bundle's rows are removed by an unscoped sync
376
301
  */
302
+ export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
303
+ return withSchedulerLock(async () => {
304
+ const { sched, plan, warnings } = await buildSchedulerSyncPlan(deps, bundleTarget, options);
305
+ const failed = new Set();
306
+ const failures = [...plan.failures];
307
+ for (const operation of plan.operations) {
308
+ try {
309
+ if (operation.kind === "remove")
310
+ await sched.uninstall(operation.nativeId);
311
+ else
312
+ await sched.install(operation.binding, operation.options);
313
+ }
314
+ catch (cause) {
315
+ if (operation.kind === "remove") {
316
+ failed.add(operation.id);
317
+ failures.push({ path: operation.nativeId, reason: errorMessage(cause) });
318
+ }
319
+ else {
320
+ failed.add(operation.binding.id);
321
+ failures.push({
322
+ path: operation.binding.source,
323
+ ref: operation.binding.logicalSource.ref,
324
+ reason: errorMessage(cause),
325
+ });
326
+ }
327
+ }
328
+ }
329
+ const applied = (ids) => ids.filter((id) => !failed.has(id));
330
+ return {
331
+ installed: applied(plan.installed),
332
+ updated: applied(plan.updated),
333
+ removed: applied(plan.removed),
334
+ unchanged: [...plan.unchanged],
335
+ skipped: [],
336
+ backend: sched.name,
337
+ failures: failures.map((failure) => ({ ...failure })),
338
+ ...(warnings.length > 0 ? { warnings } : {}),
339
+ };
340
+ });
341
+ }
377
342
  /**
378
- * Compute (but never apply) a scheduler sync plan: everything through
379
- * `finalizeSchedulerSyncPlan`'s final call, stopping strictly before
380
- * `applySchedulerSyncPlan`. Shared by `akmTasksSync` (applies the plan) and
381
- * `akmTasksSyncPlan` (#849 `--dry-run`, never applies it) so the two paths
382
- * can never drift on what "the plan" means. `prepared?.publish`, the one
383
- * deferred write-producing closure in this pipeline, is returned but never
384
- * invoked here — only `applySchedulerSyncPlan` may call it.
343
+ * `akm task sync --dry-run` (#849): the exact plan `akmTasksSync` would
344
+ * apply, previewed. It writes nothing: no config, no row.
385
345
  */
346
+ export async function akmTasksSyncPlan(deps = {}, bundleTarget, options = {}) {
347
+ const { sched, plan } = await buildSchedulerSyncPlan(deps, bundleTarget, { ...options, dryRun: true });
348
+ return renderSchedulerPlanPreview(sched.name, plan.operations, plan.unchanged, plan.failures);
349
+ }
386
350
  async function buildSchedulerSyncPlan(deps, bundleTarget, options) {
387
- const config = loadConfig();
351
+ let config = loadConfig();
388
352
  const sched = deps.backend ?? selectBackend();
389
- if (!sched.inspectBindings) {
390
- throw new ConfigError(`Scheduler backend "${sched.name}" cannot provide one coherent inspection for transactional sync.`, "INVALID_CONFIG_FILE");
353
+ const installed = await listInstalledRows(sched);
354
+ // A config that predates `scheduler.enabled` means "keep what is
355
+ // installed": the akm-written rows become the host's choice (written
356
+ // unless this is a dry run) before the desired set, which would otherwise
357
+ // remove them, is computed.
358
+ let enabledRefs = schedulerEnabledRefs(config);
359
+ if (enabledRefs === undefined) {
360
+ enabledRefs = initializeSchedulerChoice(installed, config, { write: options.dryRun !== true });
361
+ if (options.dryRun !== true)
362
+ config = loadConfig();
391
363
  }
392
- const inspection = await sched.inspectBindings({ rebind: options.rebind === true });
393
- const rawEntries = [...inspection.installed];
394
- const allEntries = rawEntries.map((entry) => {
395
- const contextPath = "contextPath" in entry ? entry.contextPath : "";
396
- // #846: recover the resolved bundle path this entry was installed
397
- // under from its own scheduler-context descriptor. Any failure (no
398
- // descriptor, unreadable, corrupt, owned by another user) leaves
399
- // ownerBundlePath unset — belongsToBundle must never treat that as
400
- // "mine".
401
- const ownerBundlePath = contextPath ? resolveInstalledOwnerPath(contextPath) : undefined;
402
- return {
403
- ...entry,
404
- ...(entry.nativeId !== undefined ? { nativeId: entry.nativeId } : {}),
405
- ...(entry.invocation !== undefined ? { invocation: Object.freeze([...entry.invocation]) } : {}),
406
- binding: "binding" in entry ? [...entry.binding] : [],
407
- contextPath,
408
- ...(ownerBundlePath !== undefined ? { ownerBundlePath } : {}),
409
- };
410
- });
411
- const nativeArtifacts = inspection.artifacts;
412
- const configuredSources = resolveConfiguredSources(config);
413
364
  const activeSources = resolveActiveConfiguredSources(config);
414
- const sourceNames = bundleTarget ? [bundleTarget] : activeSources.map((source) => source.name);
415
- const inactiveOperations = bundleTarget
416
- ? []
417
- : inactiveBundleRemovalOperations(config, configuredSources, allEntries, nativeArtifacts);
418
- if (!bundleTarget && sourceNames.length === 0 && configuredSources.length > 0) {
419
- assertSchedulerBackendInspection({ installed: allEntries, artifacts: nativeArtifacts });
420
- const plan = emptySchedulerSyncPlan(inactiveOperations);
421
- return { sched, plan, sourceSnapshots: Object.freeze([]), prepared: undefined, warnings: [] };
422
- }
423
- const selectedNames = sourceNames.length > 0 ? sourceNames : [undefined];
424
- const enabled = activeSchedulerActivations(config);
425
- const enabledActivations = new Set(enabled.map((activation) => `${activation.kind}\0${activation.ref}`));
426
- const preparedSets = [];
427
- for (const sourceName of selectedNames) {
428
- const resolved = resolveTaskReadBundle(undefined, sourceName);
429
- const stashDir = resolved.source.path;
430
- const syncTarget = sourceName !== undefined && !isPrimaryStashPath(stashDir) ? sourceName : undefined;
431
- const common = {
432
- sourceRoot: stashDir,
433
- adapterId: resolved.source.adapterId ?? detectAdapterId(stashDir),
434
- bundleName: resolved.source.name,
435
- ...(syncTarget === undefined ? { bundlePath: path.resolve(stashDir) } : {}),
436
- ...(syncTarget ? { bundleTarget: syncTarget } : {}),
437
- backend: sched.name,
438
- installed: allEntries,
439
- nativeArtifacts,
440
- inspection: Object.freeze({ installed: allEntries, artifacts: nativeArtifacts }),
441
- enabledActivations,
442
- rebind: options.rebind === true,
443
- config,
444
- resolveAsset: taskProjectionAssetResolver(config, resolved.source.name, stashDir),
445
- };
446
- preparedSets.push({
447
- common,
448
- preparedSources: await prepareSchedulerSyncSourceSet(common),
449
- ...(syncTarget ? { syncTarget } : {}),
450
- });
365
+ if (bundleTarget) {
366
+ // Only filesystem and git bundles can carry scheduler state.
367
+ const targetSource = activeSources.find((source) => source.name === bundleTarget);
368
+ if (targetSource && !isWriteCapableSourceKind(targetSource.type)) {
369
+ throw new UsageError(`Bundle "${bundleTarget}" has kind "${targetSource.type}"; task scheduling is only supported for filesystem and git bundles.`, "INVALID_FLAG_VALUE");
370
+ }
451
371
  }
452
- // Pass one validates every selected bundle and computes whether any desired
453
- // activation needs a new runtime descriptor before native mutation begins.
454
- const preflights = preparedSets.map(({ common, preparedSources }) => finalizeSchedulerSyncPlan(common, preparedSources));
455
- const warnings = [];
456
- const expectedSignature = sched.expectedSignature?.bind(sched);
457
- const needsRuntime = preflights.some((preflight) => preflight.operations.some((operation) => operation.kind !== "remove" && operation.options?.binding === undefined));
458
- const prepared = needsRuntime
459
- ? prepareSchedulerSyncRuntime(undefined, deps, warnings, allEntries.map((entry) => entry.binding))
460
- : undefined;
461
- const plans = preparedSets.map(({ common, preparedSources, syncTarget }) => finalizeSchedulerSyncPlan({
462
- ...common,
463
- ...(prepared?.options
464
- ? { installOptions: { ...prepared.options, ...(syncTarget ? { target: syncTarget } : {}) } }
465
- : syncTarget
466
- ? { installOptions: { target: syncTarget } }
467
- : {}),
468
- ...(expectedSignature
469
- ? {
470
- expectedSignature: (binding, install) => expectedSignature(binding, install),
471
- }
472
- : {}),
473
- }, preparedSources));
474
- assertNoCrossBundleSchedulerCollisions(plans);
475
- const representedRefs = new Set(plans.flatMap((candidate) => [
476
- ...candidate.desired.map((binding) => binding.logicalSource.ref),
477
- ...candidate.failures.flatMap((failure) => (failure.ref ? [failure.ref] : [])),
478
- ]));
479
- const selectedBundleNames = new Set(preparedSets.map(({ common }) => common.bundleName));
480
- const missingActivationFailures = enabled
481
- .filter((activation) => {
482
- const bundle = parseBundleRef(activation.ref).bundle;
483
- return bundle !== undefined && selectedBundleNames.has(bundle) && !representedRefs.has(activation.ref);
484
- })
485
- .map((activation) => ({
486
- path: activation.ref,
487
- ref: activation.ref,
488
- reason: `Enabled ${activation.kind} ${JSON.stringify(activation.ref)} was not found or has no schedule.`,
489
- }));
490
- const first = plans[0];
491
- if (!first)
492
- throw new ConfigError("No configured bundle is available for scheduler sync.", "INVALID_CONFIG_FILE");
493
- const plan = Object.freeze({
494
- desired: Object.freeze(plans.flatMap((candidate) => candidate.desired)),
495
- installed: Object.freeze(plans.flatMap((candidate) => candidate.installed)),
496
- updated: Object.freeze(plans.flatMap((candidate) => candidate.updated)),
497
- removed: Object.freeze([
498
- ...plans.flatMap((candidate) => candidate.removed),
499
- ...inactiveOperations.map((operation) => operation.id),
500
- ]),
501
- unchanged: Object.freeze(plans.flatMap((candidate) => candidate.unchanged)),
502
- operations: Object.freeze([...plans.flatMap((candidate) => candidate.operations), ...inactiveOperations]),
503
- sourceSnapshot: first.sourceSnapshot,
504
- failures: Object.freeze([...plans.flatMap((candidate) => candidate.failures), ...missingActivationFailures]),
505
- });
506
- return { sched, plan, sourceSnapshots: plans.map((candidate) => candidate.sourceSnapshot), prepared, warnings };
507
- }
508
- function inactiveBundleRemovalOperations(config, configuredSources, installed, artifacts) {
372
+ const configuredSources = resolveConfiguredSources(config);
373
+ const schedulable = activeSources
374
+ .filter((source) => isWriteCapableSourceKind(source.type))
375
+ .map((source) => source.name);
376
+ // No configured bundle at all: the working stash (e.g. `AKM_BUNDLE_DIR`).
377
+ const selected = bundleTarget
378
+ ? [bundleTarget]
379
+ : schedulable.length > 0 || configuredSources.length > 0
380
+ ? schedulable
381
+ : [undefined];
382
+ const enabledRefSet = new Set(enabledRefs);
383
+ const desired = [];
384
+ const failures = [];
385
+ const keepRefs = new Set();
386
+ const scopes = [];
387
+ // Each bundle is its own item: one that cannot be read is reported and
388
+ // left untouched while the others reconcile. A scoped sync has only that
389
+ // bundle, so its failure is the whole operation's.
390
+ for (const name of selected) {
391
+ try {
392
+ const resolved = resolveTaskReadBundle(undefined, name);
393
+ const stashDir = resolved.source.path;
394
+ const adapterId = resolved.source.adapterId ?? detectAdapterId(stashDir);
395
+ const compiled = await compileSchedulerSources({
396
+ sourceRoot: stashDir,
397
+ adapterId,
398
+ bundleName: resolved.source.name,
399
+ backend: sched.name,
400
+ config,
401
+ resolveAsset: taskProjectionAssetResolver(config, resolved.source.name, stashDir),
402
+ enabledRefs: enabledRefSet,
403
+ });
404
+ desired.push(...compiled.desired);
405
+ failures.push(...compiled.failures);
406
+ for (const failure of compiled.failures)
407
+ if (failure.ref)
408
+ keepRefs.add(failure.ref);
409
+ scopes.push({ ...bundleScope(resolved.source.name, stashDir), adapterId });
410
+ }
411
+ catch (cause) {
412
+ if (bundleTarget)
413
+ throw cause;
414
+ failures.push({ path: name ?? "(default bundle)", reason: errorMessage(cause) });
415
+ }
416
+ }
417
+ // An enabled ref of a synced bundle that produced nothing names a source
418
+ // that is gone or has no schedule; its row, if any, is removed below.
419
+ const represented = new Set([...desired.map((binding) => binding.logicalSource.ref), ...keepRefs]);
420
+ const synced = new Set(scopes.map((scope) => scope.bundleName));
421
+ for (const ref of enabledRefs) {
422
+ const bundle = parseBundleRef(ref).bundle;
423
+ if (bundle !== undefined && synced.has(bundle) && !represented.has(ref)) {
424
+ failures.push({ path: ref, ref, reason: `Enabled ref ${JSON.stringify(ref)} was not found or has no schedule.` });
425
+ }
426
+ }
427
+ // A disabled bundle's rows go on an unscoped sync, without reading its content.
509
428
  const inactive = new Set(configuredSources.filter((source) => source.enabled === false).map((source) => source.name));
510
- if (inactive.size === 0)
511
- return [];
512
- return installed
513
- .map((entry) => ({ entry, bundleName: installedSchedulerBundle(config, entry) }))
514
- .filter((candidate) => candidate.bundleName !== undefined && inactive.has(candidate.bundleName))
515
- .sort((left, right) => left.entry.id.localeCompare(right.entry.id))
516
- .map(({ entry, bundleName }) => {
517
- const adapterId = bundleComponentConfig(config.bundles?.[bundleName])?.adapter ?? "akm";
518
- return buildSchedulerRemoveOperation(entry.id, entry, artifacts, { adapterId, bundleName });
429
+ const extraRemovals = bundleTarget
430
+ ? []
431
+ : installed.filter((row) => {
432
+ const bundle = installedSchedulerBundle(config, row);
433
+ return bundle !== undefined && inactive.has(bundle);
434
+ });
435
+ const runtime = prepareSchedulerRuntime(deps);
436
+ const plan = planSchedulerSync({
437
+ desired,
438
+ installed,
439
+ scopes,
440
+ ...(sched.expectedSignature ? { expectedSignature: sched.expectedSignature.bind(sched) } : {}),
441
+ installOptions: runtime.options,
442
+ rebind: options.rebind === true,
443
+ extraRemovals,
444
+ keepRefs,
519
445
  });
446
+ const warnings = [];
447
+ const writesLauncher = options.rebind === true
448
+ ? plan.operations.some((operation) => operation.kind !== "remove")
449
+ : plan.installed.length > 0;
450
+ if (runtime.via === "checkout" && writesLauncher) {
451
+ warnings.push(`Scheduled tasks now run akm from a source checkout (${runtime.options.binding?.join(" ")}); they run whatever the checkout holds when they fire. Install akm with \`npm install --global akm-cli\` or a standalone release, then run \`akm task sync --rebind\`.`);
452
+ }
453
+ return { sched, plan: { ...plan, failures: [...failures, ...plan.failures] }, warnings };
520
454
  }
521
- function installedSchedulerBundle(config, entry) {
522
- const direct = entry.target ?? (entry.invocation ? scheduledInvocationBundle(entry.invocation) : undefined);
523
- if (direct !== undefined)
524
- return direct;
525
- if (entry.ownerBundlePath === undefined)
526
- return undefined;
527
- return bundleKeyForContentRoot(config, entry.ownerBundlePath);
455
+ /**
456
+ * The launcher and environment rows are written with. The environment
457
+ * follows the current policy on every sync; a row that is already installed
458
+ * keeps its launcher unless `--rebind` (see `installOptionsFor`). An injected
459
+ * backend (tests) renders with its own launcher.
460
+ */
461
+ function prepareSchedulerRuntime(deps) {
462
+ const environment = scheduledRowEnvironment();
463
+ if (deps.schedulerRuntime) {
464
+ const runtime = deps.schedulerRuntime();
465
+ return { options: { binding: runtime.binding, environment }, ...(runtime.via ? { via: runtime.via } : {}) };
466
+ }
467
+ if (deps.backend)
468
+ return { options: { environment } };
469
+ const invocation = resolveAkmInvocation();
470
+ return { options: { binding: invocation.argv, environment }, via: invocation.via };
528
471
  }
529
- function emptySchedulerSyncPlan(operations) {
530
- const sourceSnapshot = Object.freeze({
531
- adapterId: "akm",
532
- sourceRoot: "",
533
- sourceRealPath: "",
534
- sourcePhysicalIdentity: "inactive",
535
- sourceDirectoryVersion: "inactive",
536
- files: Object.freeze([]),
537
- directoryManifests: Object.freeze([]),
538
- });
539
- return Object.freeze({
540
- desired: Object.freeze([]),
541
- installed: Object.freeze([]),
542
- updated: Object.freeze([]),
543
- removed: Object.freeze(operations.map((operation) => operation.id)),
544
- unchanged: Object.freeze([]),
545
- operations: Object.freeze([...operations]),
546
- sourceSnapshot,
547
- failures: Object.freeze([]),
472
+ /** One read of the akm-owned rows, each attributed to the bundle path it names (#846). */
473
+ async function listInstalledRows(sched) {
474
+ return (await sched.list()).map((row) => {
475
+ const ownerBundlePath = installedRowBundleDir(row);
476
+ return ownerBundlePath !== undefined ? { ...row, ownerBundlePath } : row;
548
477
  });
549
478
  }
550
- function scheduledInvocationBundle(invocation) {
551
- const bundleIndex = invocation.indexOf("--bundle");
552
- if (bundleIndex >= 0)
553
- return invocation[bundleIndex + 1];
554
- if (invocation[0] === "workflow" && invocation[1] === "run" && invocation[2]) {
555
- try {
556
- return parseBundleRef(invocation[2]).bundle;
557
- }
558
- catch {
559
- return undefined;
560
- }
479
+ /**
480
+ * The `AKM_BUNDLE_DIR` an installed row names: its inline one, or the one in
481
+ * the descriptor a row written before 0.9.17-alpha.7 references. Undefined
482
+ * when it names none, or its descriptor cannot be read — which never means
483
+ * "mine".
484
+ */
485
+ function installedRowBundleDir(row) {
486
+ if (row.contextPath === undefined)
487
+ return row.environment?.AKM_BUNDLE_DIR;
488
+ try {
489
+ return readLegacySchedulerContext(row.contextPath).AKM_BUNDLE_DIR;
561
490
  }
562
- return undefined;
563
- }
564
- function assertNoCrossBundleSchedulerCollisions(plans) {
565
- const owners = new Map();
566
- for (const binding of plans.flatMap((plan) => plan.desired)) {
567
- const key = schedulerNativeArtifactKey(schedulerBindingNativeId(binding));
568
- const existing = owners.get(key);
569
- if (existing && existing.logicalSource.ref !== binding.logicalSource.ref) {
570
- throw new UsageError(`Scheduler native id ${JSON.stringify(schedulerBindingNativeId(binding))} is claimed by both ` +
571
- `${JSON.stringify(existing.logicalSource.ref)} and ${JSON.stringify(binding.logicalSource.ref)}. ` +
572
- "Rename one task before enabling both.", "RESOURCE_ALREADY_EXISTS");
573
- }
574
- owners.set(key, binding);
491
+ catch {
492
+ return undefined;
575
493
  }
576
494
  }
577
- export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
578
- const { sched, plan, sourceSnapshots, prepared, warnings } = await buildSchedulerSyncPlan(deps, bundleTarget, options);
579
- await applySchedulerSyncPlan(sched, plan, prepared?.publish && plan.operations.some((operation) => operation.kind !== "remove")
580
- ? prepared.publish
581
- : undefined, sourceSnapshots);
582
- return {
583
- installed: [...plan.installed],
584
- updated: [...plan.updated],
585
- removed: [...plan.removed],
586
- unchanged: [...plan.unchanged],
587
- skipped: [],
588
- backend: sched.name,
589
- failures: plan.failures.map((failure) => ({ ...failure })),
590
- ...(warnings.length > 0 ? { warnings } : {}),
591
- };
495
+ /** The primary bundle proves its rows by path (#846); any other bundle by its config name. */
496
+ function bundleScope(bundleName, stashDir) {
497
+ return isPrimaryStashPath(stashDir) ? { bundleName, bundlePath: path.resolve(stashDir) } : { bundleName };
592
498
  }
593
- /**
594
- * `akm task sync --dry-run` (#849): compute the exact same plan
595
- * `akmTasksSync` would apply, then return a non-mutating preview instead of
596
- * calling `applySchedulerSyncPlan`. `buildSchedulerSyncPlan` is shared with
597
- * the real sync path specifically so this can never see a different plan
598
- * than the one a real sync would apply — and specifically so this function
599
- * never even holds a reference to a callable `publish` closure past this
600
- * point: `prepared.publish`, if any, is dropped on the floor here, never
601
- * invoked. Zero durable writes, mirroring `akm workflow plan`.
602
- */
603
- export async function akmTasksSyncPlan(deps = {}, bundleTarget, options = {}) {
604
- const { sched, plan } = await buildSchedulerSyncPlan(deps, bundleTarget, options);
605
- return renderSchedulerSyncPlanPreview(sched.name, plan);
499
+ function installedSchedulerBundle(config, row) {
500
+ const direct = row.target ?? scheduledInvocationBundle(row.invocation);
501
+ if (direct !== undefined)
502
+ return direct;
503
+ if (row.ownerBundlePath === undefined)
504
+ return undefined;
505
+ return bundleKeyForContentRoot(config, row.ownerBundlePath);
606
506
  }
607
507
  /**
608
- * Classify one installed scheduler binding as a prune candidate (#851), using
609
- * the same two signals `doctor`'s `inspectInstalledBinding` already computes
610
- * — deliberately narrower than that function's full `status` set. Only an
611
- * entry whose ownership can NEVER be resolved (`invalid-context`) or whose
612
- * resolved owner no longer exists on disk (`dead-bundle-path`) is a
613
- * candidate; `missing-path` (e.g. the akm binary itself moved) is a
614
- * different failure mode and is intentionally NOT folded in here, per the
615
- * scoping in #851 — an entry that still resolves to a live bundle is never a
616
- * candidate, full stop.
508
+ * Why `akm task prune` (#851) would remove an installed row: a row written
509
+ * before 0.9.17-alpha.7 whose descriptor cannot be read (`invalid-context`),
510
+ * or one naming a bundle directory that is gone (`dead-bundle-path`). A row
511
+ * that still resolves to a live bundle is never a candidate.
617
512
  */
618
513
  function classifyPruneCandidate(entry) {
619
- let ownerBundlePath;
620
- try {
621
- ownerBundlePath = validateSchedulerContextDescriptor(entry.contextPath).environment.AKM_BUNDLE_DIR;
514
+ let bundleDir;
515
+ if (entry.contextPath !== undefined) {
516
+ try {
517
+ bundleDir = readLegacySchedulerContext(entry.contextPath).AKM_BUNDLE_DIR;
518
+ }
519
+ catch {
520
+ return "invalid-context";
521
+ }
522
+ // Every descriptor akm wrote names the bundle; one that does not cannot be attributed.
523
+ if (bundleDir === undefined)
524
+ return "invalid-context";
622
525
  }
623
- catch {
624
- return "invalid-context";
526
+ else {
527
+ bundleDir = entry.environment?.AKM_BUNDLE_DIR;
625
528
  }
626
- if (ownerBundlePath !== undefined && !fs.existsSync(ownerBundlePath))
529
+ if (bundleDir !== undefined && !fs.existsSync(bundleDir))
627
530
  return "dead-bundle-path";
628
531
  return undefined;
629
532
  }
630
- async function buildTaskPrunePlan(deps = {}, options = {}) {
631
- const sched = deps.backend ?? selectBackend();
632
- if (!sched.inspectBindings) {
633
- throw new ConfigError(`Scheduler backend "${sched.name}" cannot provide one coherent inspection for prune.`, "INVALID_CONFIG_FILE");
634
- }
635
- const inspection = await sched.inspectBindings({});
636
- const candidates = new Map();
637
- for (const entry of inspection.installed) {
638
- const reason = classifyPruneCandidate(entry);
639
- if (reason)
640
- candidates.set(entry.id, reason);
641
- }
642
- const requestedIds = options.id?.filter((id) => id.length > 0) ?? [];
643
- for (const id of requestedIds) {
644
- if (!candidates.has(id)) {
645
- throw new UsageError(`Scheduler binding ${JSON.stringify(id)} is not an orphaned prune candidate ` +
646
- "(either not installed, or it still resolves to a live bundle) — refusing to prune it.", "INVALID_FLAG_VALUE");
647
- }
648
- }
649
- const idFilter = requestedIds.length > 0 ? new Set(requestedIds) : undefined;
650
- const resolved = resolveTaskReadBundle(undefined, undefined);
651
- const bundleContext = {
652
- adapterId: resolved.source.adapterId ?? detectAdapterId(resolved.source.path),
653
- bundleName: resolved.source.name,
654
- };
655
- const operations = [];
656
- for (const entry of inspection.installed) {
657
- const reason = candidates.get(entry.id);
658
- if (!reason)
659
- continue;
660
- if (idFilter && !idFilter.has(entry.id))
661
- continue;
662
- const operation = buildSchedulerRemoveOperation(entry.id, entry, inspection.artifacts, bundleContext);
663
- operations.push(Object.freeze({ ...operation, reason }));
664
- }
665
- return { sched, operations: Object.freeze(operations) };
666
- }
667
533
  /**
668
- * `akm task prune` (#851): remove installed scheduler bindings `sync` can
669
- * never reclaim because their own `--scheduler-context` descriptor doesn't
670
- * resolve to a live bundle. Defaults to dry-run — no `--yes` and no `--id`
671
- * means zero backend calls that could mutate anything, matching
672
- * `akmTasksSyncPlan`'s zero-write guarantee. `--id` (one or more) narrows
673
- * execution to exactly those bindings; `--yes` alone executes every
674
- * currently-computed candidate. Both still return the full preview so the
675
- * plan is never silent about what it did.
534
+ * `akm task prune` (#851): remove installed rows `sync` can never reclaim
535
+ * because they no longer resolve to a live bundle. It scans
536
+ * every installed row, not one bundle's. Without `--yes` it only previews and
537
+ * writes nothing; `--id` narrows it to named candidates.
676
538
  */
677
539
  export async function akmTasksPrune(deps = {}, options = {}) {
678
- const { sched, operations } = await buildTaskPrunePlan(deps, options);
679
- const preview = renderSchedulerPlanPreview(sched.name, operations);
680
- if (!options.yes) {
681
- return { backend: sched.name, dryRun: true, preview, removed: [] };
682
- }
683
- await applySchedulerTransaction(sched, operations, {
684
- initialExpectations: operations.map((operation) => operation.expected),
685
- });
686
- return {
687
- backend: sched.name,
688
- dryRun: false,
689
- preview,
690
- removed: operations.map((operation) => operation.id),
540
+ const prune = async () => {
541
+ const sched = deps.backend ?? selectBackend();
542
+ const operations = pruneOperations(await sched.list(), options.id ?? []);
543
+ const preview = renderSchedulerPlanPreview(sched.name, operations);
544
+ if (!options.yes)
545
+ return { backend: sched.name, dryRun: true, preview, removed: [] };
546
+ for (const operation of operations)
547
+ await sched.uninstall(operation.nativeId);
548
+ return { backend: sched.name, dryRun: false, preview, removed: operations.map((operation) => operation.id) };
691
549
  };
550
+ return options.yes ? withSchedulerLock(prune) : prune();
551
+ }
552
+ function pruneOperations(installed, requestedIds) {
553
+ const candidates = installed.flatMap((row) => {
554
+ const reason = classifyPruneCandidate(row);
555
+ return reason ? [{ row, reason }] : [];
556
+ });
557
+ const ids = requestedIds.filter((id) => id.length > 0);
558
+ for (const id of ids) {
559
+ if (!candidates.some(({ row }) => row.id === id)) {
560
+ throw new UsageError(`Scheduler binding ${JSON.stringify(id)} is not an orphaned prune candidate ` +
561
+ "(either not installed, or it still resolves to a live bundle) — refusing to prune it.", "INVALID_FLAG_VALUE");
562
+ }
563
+ }
564
+ return candidates
565
+ .filter(({ row }) => ids.length === 0 || ids.includes(row.id))
566
+ .map(({ row, reason }) => ({ kind: "remove", id: row.id, nativeId: installedRowNativeId(row), reason }));
692
567
  }
693
568
  export async function akmTasksDoctor(deps = {}) {
694
569
  const warnings = [];
695
- let invocation = {
696
- argv: [],
697
- via: "unresolved",
698
- };
570
+ let invocation = { argv: [], via: "unresolved" };
699
571
  try {
700
572
  const r = (deps.resolveInvocation ?? resolveAkmInvocation)();
701
- invocation = { argv: r.argv, via: r.via, kind: r.kind, eligible: r.eligible };
573
+ invocation = { argv: r.argv, via: r.via };
702
574
  }
703
575
  catch (err) {
704
576
  warnings.push(err instanceof Error ? err.message : String(err));
@@ -718,7 +590,7 @@ export async function akmTasksDoctor(deps = {}) {
718
590
  warnings.push(`Unable to inspect installed ${backend} definitions: ${error instanceof Error ? error.message : String(error)}`);
719
591
  }
720
592
  }
721
- const bindings = groupInstalledBindings(installed, invocation);
593
+ const bindings = groupInstalledBindings(installed);
722
594
  // Report the EFFECTIVE engine view — the same one the runner resolves —
723
595
  // so doctor never says "no engine" on an install where tasks actually run.
724
596
  const { config } = withEngineFallback(loadConfig());
@@ -748,7 +620,6 @@ export async function akmTasksDoctor(deps = {}) {
748
620
  defaultStrategy: improveStrategyName,
749
621
  enabled: triage.enabled === true,
750
622
  applyMode: triage.applyMode ?? "queue",
751
- policy: triage.policy ?? "personal-stash",
752
623
  }
753
624
  : undefined;
754
625
  return {
@@ -769,298 +640,11 @@ export async function akmTasksDoctor(deps = {}) {
769
640
  };
770
641
  }
771
642
  // ── helpers ─────────────────────────────────────────────────────────────────
772
- async function applySchedulerSyncPlan(backend, plan, publish, sourceSnapshots = [plan.sourceSnapshot]) {
773
- await applySchedulerTransaction(backend, plan.operations, {
774
- initialExpectations: plan.operations.map((operation) => operation.expected),
775
- assertReadSet: () => {
776
- for (const snapshot of sourceSnapshots)
777
- assertSchedulerSourceSnapshot(snapshot);
778
- },
779
- beforeOperation: (_operation, index) => {
780
- if (index === 0)
781
- publish?.();
782
- },
783
- });
784
- }
785
- async function applySchedulerTransaction(backend, operations, hooks) {
786
- if (operations.length === 0) {
787
- hooks.assertReadSet?.();
788
- await hooks.afterOperations?.();
789
- hooks.assertReadSet?.();
790
- return;
791
- }
792
- hooks.assertReadSet?.();
793
- if (!backend.snapshotBindings || !backend.restoreBindings) {
794
- throw new ConfigError(`Scheduler backend "${backend.name}" cannot snapshot and restore a whole-set transaction.`, "INVALID_CONFIG_FILE");
795
- }
796
- const nativeIds = [
797
- ...new Set(operations.map((operation) => operation.kind === "remove" ? operation.nativeId : schedulerBindingNativeId(operation.binding))),
798
- ];
799
- const snapshot = await backend.snapshotBindings(nativeIds);
800
- assertSchedulerTransactionSnapshot(snapshot, nativeIds, hooks.initialExpectations);
801
- const rollbackExpected = schedulerRollbackExpectations(snapshot, operations);
802
- hooks.assertReadSet?.();
803
- try {
804
- for (const [index, operation] of operations.entries()) {
805
- hooks.assertReadSet?.();
806
- await hooks.beforeOperation?.(operation, index);
807
- hooks.assertReadSet?.();
808
- if (operation.kind === "remove")
809
- await backend.uninstall(operation.nativeId, operation.expected);
810
- else
811
- await backend.install(operation.binding, operation.options, operation.expected);
812
- hooks.assertReadSet?.();
813
- }
814
- hooks.assertReadSet?.();
815
- await hooks.afterOperations?.();
816
- hooks.assertReadSet?.();
817
- }
818
- catch (primaryError) {
819
- let externalRollbackError;
820
- try {
821
- await hooks.rollbackExternal?.();
822
- }
823
- catch (error) {
824
- externalRollbackError = error;
825
- }
826
- let nativeRollbackError;
827
- const externalStateAllowsNativeRollback = externalRollbackError !== undefined &&
828
- hooks.allowNativeRollbackAfterExternalFailure?.(externalRollbackError) === true;
829
- if (!(externalRollbackError && hooks.suppressNativeRollbackWhenExternalFails && !externalStateAllowsNativeRollback)) {
830
- try {
831
- await backend.restoreBindings(snapshot, rollbackExpected);
832
- }
833
- catch (error) {
834
- nativeRollbackError = error;
835
- }
836
- }
837
- const rollbackErrors = [externalRollbackError, nativeRollbackError].filter((error) => error !== undefined);
838
- if (rollbackErrors.length > 0) {
839
- throw new AggregateError([primaryError, ...rollbackErrors], `Scheduler transaction failed and rollback was incomplete: ${errorMessage(primaryError)}`);
840
- }
841
- throw primaryError;
842
- }
843
- }
844
- function schedulerRollbackExpectations(snapshot, operations) {
845
- return Object.freeze(snapshot.nativeIds.map((nativeId) => {
846
- const prior = snapshot.artifacts.find((artifact) => schedulerNativeArtifactKey(artifact.nativeId) === schedulerNativeArtifactKey(nativeId));
847
- const allowed = [];
848
- if (prior) {
849
- if (prior.fingerprint === undefined) {
850
- throw new ConfigError(`Scheduler backend snapshot for ${JSON.stringify(nativeId)} has no exact fingerprint.`, "INVALID_CONFIG_FILE");
851
- }
852
- allowed.push(Object.freeze({
853
- state: "present",
854
- ...(prior.bindingId !== undefined ? { bindingId: prior.bindingId } : {}),
855
- ...(prior.invocation !== undefined ? { invocation: Object.freeze([...prior.invocation]) } : {}),
856
- fingerprint: prior.fingerprint,
857
- }));
858
- }
859
- else {
860
- allowed.push(Object.freeze({ state: "absent" }));
861
- }
862
- const matchingOperations = operations.filter((candidate) => {
863
- const operationNativeId = candidate.kind === "remove" ? candidate.nativeId : schedulerBindingNativeId(candidate.binding);
864
- return schedulerNativeArtifactKey(operationNativeId) === schedulerNativeArtifactKey(nativeId);
865
- });
866
- for (const operation of matchingOperations) {
867
- if (operation.kind === "remove") {
868
- if (!allowed.some((state) => state.state === "absent")) {
869
- allowed.push(Object.freeze({ state: "absent" }));
870
- }
871
- continue;
872
- }
873
- if (operation.resultFingerprint === undefined) {
874
- throw new ConfigError(`Scheduler backend cannot freeze the post-mutation fingerprint for ${JSON.stringify(nativeId)}.`, "INVALID_CONFIG_FILE");
875
- }
876
- allowed.push(Object.freeze({
877
- state: "present",
878
- bindingId: operation.binding.id,
879
- invocation: Object.freeze([...operation.binding.invocation]),
880
- fingerprint: operation.resultFingerprint,
881
- }));
882
- }
883
- return Object.freeze({ nativeId, allowed: Object.freeze(allowed) });
884
- }));
885
- }
886
- function assertSchedulerTransactionSnapshot(snapshot, nativeIds, initialExpectations) {
887
- if (!snapshot ||
888
- !Array.isArray(snapshot.nativeIds) ||
889
- !Array.isArray(snapshot.artifacts) ||
890
- nativeIds.some((nativeId) => !snapshot.nativeIds.includes(nativeId))) {
891
- throw new ConfigError("Scheduler backend returned an incomplete transaction snapshot.", "INVALID_CONFIG_FILE");
892
- }
893
- const snapshotKeys = snapshot.nativeIds.map(schedulerNativeArtifactKey);
894
- const requestedKeys = nativeIds.map(schedulerNativeArtifactKey);
895
- if (snapshotKeys.length !== requestedKeys.length ||
896
- new Set(snapshotKeys).size !== snapshotKeys.length ||
897
- snapshotKeys.some((key) => !requestedKeys.includes(key))) {
898
- throw new ConfigError("Scheduler backend returned an inexact normalized transaction snapshot set.", "INVALID_CONFIG_FILE");
899
- }
900
- for (const expected of initialExpectations) {
901
- const artifact = assertSchedulerNativeArtifactCardinality(snapshot.artifacts, expected.nativeId, expected.state === "absent" ? 0 : 1);
902
- assertSchedulerMutationArtifact(artifact, expected);
903
- }
904
- }
905
- async function prepareTaskAddSchedulerTransaction(input) {
906
- if (!input.sched.inspectBindings) {
907
- throw new ConfigError(`Scheduler backend "${input.sched.name}" cannot provide one coherent inspection for transactional add.`, "INVALID_CONFIG_FILE");
908
- }
909
- if (!input.sched.snapshotBindings || !input.sched.restoreBindings || !input.sched.expectedSignature) {
910
- throw new ConfigError(`Scheduler backend "${input.sched.name}" cannot provide exact snapshot, restore, and signature contracts for transactional add.`, "INVALID_CONFIG_FILE");
911
- }
912
- const inspection = await input.sched.inspectBindings({ rebind: input.rebind });
913
- const installedEntries = [...inspection.installed];
914
- const nativeArtifacts = [...inspection.artifacts];
915
- const seenNativeKeys = new Set();
916
- for (const artifact of nativeArtifacts) {
917
- const key = schedulerNativeArtifactKey(artifact.nativeId);
918
- if (seenNativeKeys.has(key)) {
919
- throw new UsageError(`Scheduler inspection has duplicate normalized native artifact ${JSON.stringify(artifact.nativeId)}.`, "RESOURCE_ALREADY_EXISTS");
920
- }
921
- seenNativeKeys.add(key);
922
- }
923
- for (const binding of input.taskBindings) {
924
- assertNoForeignSchedule(installedEntries, binding.id, input.ownerTarget);
925
- }
926
- const taskEntries = installedEntries.filter((entry) => installedEntryRunsTask(entry, input.id));
927
- const foreignTaskEntry = taskEntries.find((entry) => !sameBundle(entry.target, input.ownerTarget));
928
- if (foreignTaskEntry) {
929
- throw new UsageError(foreignScheduleMessage(input.id, foreignTaskEntry.target), "RESOURCE_ALREADY_EXISTS");
930
- }
931
- assertSchedulerNativeArtifactOwnership(input.taskBindings, nativeArtifacts);
932
- const primary = input.taskBindings[0];
933
- if (!primary)
934
- throw new Error("invariant: scheduler transaction has no desired binding");
935
- const installedEntry = installedEntries.find((entry) => entry.id === primary.id) ?? taskEntries[0];
936
- const preparedRuntime = installedEntry && !input.rebind
937
- ? {
938
- options: {
939
- ...input.installOpts,
940
- binding: Object.freeze([...installedEntry.binding]),
941
- contextPath: installedEntry.contextPath,
942
- },
943
- }
944
- : prepareSchedulerSyncRuntime(input.installOpts, input.deps, []);
945
- const runtimeOpts = preparedRuntime.options;
946
- const removals = taskEntries.map((entry) => {
947
- const invocation = entry.invocation;
948
- const nativeId = entry.nativeId ?? schedulerNativeBindingId(entry.id);
949
- const artifact = assertSchedulerNativeArtifactCardinality(nativeArtifacts, nativeId, 1);
950
- if (!artifact?.fingerprint || artifact.bindingId !== entry.id) {
951
- throw new UsageError(`Installed scheduler binding ${JSON.stringify(entry.id)} has no exact coherent fingerprint.`, "RESOURCE_ALREADY_EXISTS");
952
- }
953
- const logicalSource = primary.logicalSource;
954
- const ordinal = invocation ? schedulerBindingOrdinal(entry.id, logicalSource, invocation) : undefined;
955
- if (!invocation || ordinal === undefined) {
956
- warn(`Installed scheduler binding ${JSON.stringify(entry.id)} (native id ${JSON.stringify(nativeId)}) could not be exactly parsed — likely a hand-edited entry; replacing it without a compare-and-swap guard.`);
957
- return Object.freeze({
958
- kind: "remove",
959
- id: entry.id,
960
- nativeId,
961
- });
962
- }
963
- return Object.freeze({
964
- kind: "remove",
965
- id: entry.id,
966
- nativeId,
967
- expected: Object.freeze({
968
- bindingId: entry.id,
969
- nativeId,
970
- logicalSource,
971
- ordinal,
972
- invocation: Object.freeze([...invocation]),
973
- fingerprint: artifact.fingerprint,
974
- }),
975
- });
976
- });
977
- const installs = input.taskBindings.map((binding) => {
978
- const nativeId = schedulerBindingNativeId(binding);
979
- const resultFingerprint = input.sched.expectedSignature(binding, runtimeOpts);
980
- if (!resultFingerprint) {
981
- throw new ConfigError(`Scheduler backend "${input.sched.name}" cannot freeze the post-install fingerprint for ${JSON.stringify(binding.id)}.`, "INVALID_CONFIG_FILE");
982
- }
983
- return Object.freeze({
984
- kind: "install",
985
- binding,
986
- expected: Object.freeze({
987
- state: "absent",
988
- bindingId: binding.id,
989
- nativeId,
990
- logicalSource: binding.logicalSource,
991
- ordinal: binding.ordinal,
992
- invocation: binding.invocation,
993
- }),
994
- resultFingerprint,
995
- ...(runtimeOpts ? { options: runtimeOpts } : {}),
996
- });
997
- });
998
- const initialByKey = new Map();
999
- for (const removal of removals) {
1000
- if (removal.kind !== "remove")
1001
- continue;
1002
- if (!removal.expected)
1003
- continue;
1004
- initialByKey.set(schedulerNativeArtifactKey(removal.nativeId), Object.freeze({ ...removal.expected, state: "present" }));
1005
- }
1006
- for (const install of installs) {
1007
- if (install.kind === "remove")
1008
- continue;
1009
- const key = schedulerNativeArtifactKey(schedulerBindingNativeId(install.binding));
1010
- if (!initialByKey.has(key))
1011
- initialByKey.set(key, install.expected);
1012
- }
1013
- return Object.freeze({
1014
- runtimeOpts,
1015
- ...(preparedRuntime.publish ? { publishRuntime: preparedRuntime.publish } : {}),
1016
- operations: Object.freeze([...removals, ...installs]),
1017
- initialExpectations: Object.freeze([...initialByKey.values()]),
1018
- publishOperationIndex: removals.length,
1019
- });
1020
- }
1021
- function prepareSchedulerSyncRuntime(base, deps, warnings, installedBindings = []) {
1022
- if (deps.backend && !deps.schedulerRuntime)
1023
- return base ? { options: base } : {};
1024
- if (deps.schedulerRuntime) {
1025
- const runtime = deps.schedulerRuntime();
1026
- warnIneligibleRebind(runtime, warnings, installedBindings);
1027
- return { options: { ...base, binding: runtime.binding, contextPath: runtime.contextPath } };
1028
- }
1029
- const invocation = resolveAndValidateSchedulerInvocation();
1030
- warnIneligibleRebind(invocation, warnings, installedBindings);
1031
- const descriptor = schedulerContextDescriptor();
1032
- const contextPath = schedulerContextPath(descriptor);
1033
- return {
1034
- options: { ...base, binding: invocation.binding, contextPath },
1035
- publish: () => {
1036
- const written = writeSchedulerContextDescriptor(descriptor);
1037
- if (written !== contextPath) {
1038
- throw new ConfigError("Scheduler context descriptor path changed after preflight.", "INVALID_CONFIG_FILE");
1039
- }
1040
- },
1041
- };
1042
- }
1043
- function resolveAndValidateSchedulerInvocation() {
1044
- const invocation = resolveAkmInvocation();
1045
- return { binding: invocation.argv, contextPath: "", eligible: invocation.eligible, kind: invocation.kind };
1046
- }
1047
- function warnIneligibleRebind(runtime, warnings, installedBindings) {
1048
- if (runtime.eligible !== false || warnings.length > 0)
1049
- return;
1050
- // #868 residue: binding every currently-installed entry to the SAME
1051
- // invocation it already carries changes nothing — this is the steady
1052
- // state of an image-baked install re-running `task sync` on a timer.
1053
- // Only warn when the bind actually moves an entry to a different
1054
- // invocation.
1055
- if (installedBindings.length > 0 && installedBindings.every((bound) => sameArgv(bound, runtime.binding)))
1056
- return;
1057
- warnings.push(`Scheduled tasks are bound to an ineligible ${runtime.kind ?? "unknown"} invocation (${runtime.binding.join(" ")}); scheduled runs will invoke a mutable, unproven binary. Install akm via \`npm install --global akm-cli\` or a standalone release, then re-run \`akm task sync --rebind\`.`);
1058
- }
1059
- function groupInstalledBindings(entries, invocation) {
643
+ function groupInstalledBindings(entries) {
1060
644
  const groups = new Map();
1061
645
  for (const entry of entries) {
1062
646
  const argv = [...entry.binding];
1063
- const status = inspectInstalledBinding(entry, invocation);
647
+ const status = inspectInstalledBinding(entry);
1064
648
  const key = JSON.stringify([argv, entry.contextPath, status]);
1065
649
  const existing = groups.get(key);
1066
650
  if (existing) {
@@ -1069,192 +653,43 @@ function groupInstalledBindings(entries, invocation) {
1069
653
  }
1070
654
  groups.set(key, {
1071
655
  argv,
1072
- contextPath: entry.contextPath,
656
+ ...(entry.contextPath !== undefined ? { contextPath: entry.contextPath } : {}),
1073
657
  taskIds: [entry.id],
1074
658
  status,
1075
659
  });
1076
660
  }
1077
661
  return [...groups.values()].map((group) => ({ ...group, taskIds: group.taskIds.sort() }));
1078
662
  }
1079
- /** Best-effort recovery of an installed binding's owning bundle path (#846). */
1080
- function resolveInstalledOwnerPath(contextPath) {
1081
- try {
1082
- return validateSchedulerContextDescriptor(contextPath).environment.AKM_BUNDLE_DIR;
1083
- }
1084
- catch {
1085
- return undefined;
1086
- }
1087
- }
1088
- function inspectInstalledBinding(entry, invocation) {
663
+ function inspectInstalledBinding(entry) {
1089
664
  const status = [];
1090
665
  const binding = entry.binding;
1091
- if (!(invocation.eligible === true && sameArgv(binding, invocation.argv)) &&
1092
- binding.some((part) => /(?:^|[\\/])src[\\/]cli\.ts$|(?:^|[\\/])dist[\\/](?:cli\.js|cli-node\.mjs)$/i.test(part) ||
1093
- (path.isAbsolute(part) && hasGitAncestor(part)))) {
666
+ if (isCheckoutInvocation(binding))
1094
667
  status.push("checkout");
1095
- }
1096
668
  if (binding.some((part) => part === "akm" || part === "bun" || part === "node"))
1097
669
  status.push("path-selected");
1098
- try {
1099
- validateSchedulerContextDescriptor(entry.contextPath);
1100
- }
1101
- catch {
1102
- status.push("invalid-context");
670
+ if (entry.contextPath !== undefined) {
671
+ try {
672
+ readLegacySchedulerContext(entry.contextPath);
673
+ }
674
+ catch {
675
+ status.push("invalid-context");
676
+ }
1103
677
  }
1104
- const absolutePaths = [...binding.filter((part) => path.isAbsolute(part)), entry.contextPath];
678
+ const absolutePaths = binding.filter((part) => path.isAbsolute(part));
1105
679
  if (absolutePaths.some((part) => !fs.existsSync(part)))
1106
680
  status.push("missing-path");
1107
681
  if (status.length === 0)
1108
682
  status.push("ok");
1109
683
  return status;
1110
684
  }
1111
- function sameArgv(left, right) {
1112
- return left.length === right.length && left.every((value, index) => value === right[index]);
1113
- }
1114
- function hasGitAncestor(file) {
1115
- let current;
1116
- try {
1117
- current = path.dirname(fs.realpathSync(file));
1118
- }
1119
- catch {
1120
- return false;
1121
- }
1122
- for (;;) {
1123
- if (fs.existsSync(path.join(current, ".git")))
1124
- return true;
1125
- const parent = path.dirname(current);
1126
- if (parent === current)
1127
- return false;
1128
- current = parent;
1129
- }
1130
- }
1131
685
  function taskAssetRef(id) {
1132
686
  return { type: "task", name: id };
1133
687
  }
1134
- function captureTaskSourceExpectation(filePathInput, rootInput) {
1135
- const filePath = path.resolve(filePathInput);
1136
- const root = path.resolve(rootInput);
1137
- const lexicalRelative = path.relative(root, filePath);
1138
- if (lexicalRelative === "" || lexicalRelative.startsWith("..") || path.isAbsolute(lexicalRelative)) {
1139
- throw new UsageError(`${filePathInput} resolves outside the task source root.`, "PATH_ESCAPE_VIOLATION");
1140
- }
1141
- const rootRealPath = fs.realpathSync(root);
1142
- const rootStat = fs.statSync(rootRealPath, { bigint: true });
1143
- if (!rootStat.isDirectory()) {
1144
- throw new UsageError(`${root} is not a task source directory.`, "INVALID_FLAG_VALUE");
1145
- }
1146
- const common = { filePath, rootRealPath };
1147
- let descriptor;
1148
- try {
1149
- descriptor = fs.openSync(filePath, fs.constants.O_RDONLY);
1150
- let before = fs.fstatSync(descriptor, { bigint: true });
1151
- if (!before.isFile()) {
1152
- throw new UsageError(`${filePath} is not a regular task source.`, "INVALID_FLAG_VALUE");
1153
- }
1154
- let bytes = fs.readFileSync(descriptor);
1155
- const torn = !sameTaskSourceStat(before, fs.fstatSync(descriptor, { bigint: true })) ||
1156
- BigInt(bytes.byteLength) !== before.size;
1157
- if (torn) {
1158
- fs.closeSync(descriptor);
1159
- descriptor = fs.openSync(filePath, fs.constants.O_RDONLY);
1160
- before = fs.fstatSync(descriptor, { bigint: true });
1161
- bytes = fs.readFileSync(descriptor);
1162
- warn(`${filePath} changed while its guarded bytes were read; retried once and proceeding with the latest read (its SHA-256 is re-verified before anything is published).`);
1163
- }
1164
- const realPath = fs.realpathSync(filePath);
1165
- const physicalRelative = path.relative(rootRealPath, realPath);
1166
- if (physicalRelative === "" || physicalRelative.startsWith("..") || path.isAbsolute(physicalRelative)) {
1167
- throw new UsageError(`${filePath} resolves outside the task source root.`, "PATH_ESCAPE_VIOLATION");
1168
- }
1169
- let content;
1170
- try {
1171
- content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
1172
- }
1173
- catch {
1174
- throw new UsageError(`${filePath} contains invalid UTF-8 bytes.`, "INVALID_FLAG_VALUE");
1175
- }
1176
- return Object.freeze({
1177
- state: "present",
1178
- ...common,
1179
- realPath,
1180
- size: bytes.byteLength,
1181
- sha256: createHash("sha256").update(bytes).digest("hex"),
1182
- bytesBase64: bytes.toString("base64"),
1183
- content,
1184
- });
1185
- }
1186
- catch (cause) {
1187
- if (cause.code === "ENOENT") {
1188
- return Object.freeze({ state: "absent", ...common });
1189
- }
1190
- if (cause instanceof UsageError)
1191
- throw cause;
1192
- throw new UsageError(`${filePath} could not be guarded as a contained regular task source: ${errorMessage(cause)}`, "PATH_ESCAPE_VIOLATION");
1193
- }
1194
- finally {
1195
- if (descriptor !== undefined)
1196
- fs.closeSync(descriptor);
1197
- }
1198
- }
1199
- // TOCTOU note: this compares CONTENT (state + sha256), not filesystem
1200
- // identity (inode/mtime/ctime/directory timestamps) — the same split already
1201
- // applied to the task migrator in 0.9.5. An unrelated touch to the file or
1202
- // its containing directory must not trip a "changed after planning" refusal;
1203
- // only a real content change should.
1204
- function assertTaskSourceExpectation(expected) {
1205
- const actual = captureTaskSourceExpectation(expected.filePath, expected.rootRealPath);
1206
- if (!sameTaskSourceExpectation(actual, expected)) {
1207
- throw new UsageError(`Task source ${JSON.stringify(expected.filePath)} changed after transaction planning.`, "RESOURCE_ALREADY_EXISTS");
1208
- }
1209
- }
1210
- function sameTaskSourceExpectation(left, right) {
1211
- if (left.state !== right.state || left.filePath !== right.filePath || left.rootRealPath !== right.rootRealPath) {
1212
- return false;
1213
- }
1214
- if (left.state === "absent")
1215
- return true;
1216
- return left.sha256 === right.sha256;
1217
- }
1218
- function assertTaskSourceRestored(expected) {
1219
- const actual = captureTaskSourceExpectation(expected.filePath, expected.rootRealPath);
1220
- const restored = actual.state === expected.state &&
1221
- (actual.state === "absent" ||
1222
- (expected.state === "present" && actual.sha256 === expected.sha256 && actual.content === expected.content));
1223
- if (!restored) {
1224
- throw new UsageError(`Task source ${JSON.stringify(expected.filePath)} could not be restored without replacing a concurrent owner.`, "RESOURCE_ALREADY_EXISTS");
1225
- }
1226
- }
1227
- class TaskSourceRestoredBoundaryError extends Error {
1228
- cause;
1229
- constructor(cause) {
1230
- super(errorMessage(cause));
1231
- this.name = "TaskSourceRestoredBoundaryError";
1232
- this.cause = cause;
1233
- }
1234
- }
1235
- function sameTaskSourceStat(left, right) {
1236
- return (left.dev === right.dev &&
1237
- left.ino === right.ino &&
1238
- left.mode === right.mode &&
1239
- left.size === right.size &&
1240
- left.mtimeNs === right.mtimeNs &&
1241
- left.ctimeNs === right.ctimeNs);
1242
- }
1243
- function hashTaskSource(source) {
1244
- return createHash("sha256").update(Buffer.from(source, "utf8")).digest("hex");
1245
- }
1246
- /**
1247
- * Resolve the bundle a mutating/run task command targets. Returns the resolved
1248
- * write/read target, its stash path, and the `--bundle <bundle>` token to embed
1249
- * in scheduled invocations. The primary bundle uses the target-less form.
1250
- */
1251
- function resolveTaskBundle(target, opts) {
688
+ /** The bundle `task add` writes into: its write target, config, stash path, and name. */
689
+ function resolveTaskBundle(target) {
1252
690
  const config = loadConfig();
1253
- const selected = resolveWriteTarget(config, target, { requireWritable: opts.requireWritable });
1254
- const resolved = opts.requireWritable ? prepareWriteTargetForMutation(selected) : selected;
1255
- const stashDir = resolved.source.path;
1256
- const installTarget = isPrimaryStashPath(stashDir) ? undefined : (resolved.selector ?? resolved.source.name);
1257
- return { resolved, config, stashDir, bundleName: resolved.source.name, installTarget };
691
+ const resolved = prepareWriteTargetForMutation(resolveWriteTarget(config, target, { requireWritable: true }));
692
+ return { resolved, config, stashDir: resolved.source.path, bundleName: resolved.source.name };
1258
693
  }
1259
694
  function taskProjectionAssetResolver(config, bundleName, bundleRoot) {
1260
695
  return async ({ bundle, type, name }) => {
@@ -1299,8 +734,9 @@ export function resolveTaskReadBundle(refBundle, flagBundle) {
1299
734
  * New scheduler bindings always carry a canonical `--bundle <owner>` token,
1300
735
  * including an env-selected working stash. That stash need not be persisted in
1301
736
  * config (CI, one-shot tools, and fresh installs commonly use only
1302
- * AKM_BUNDLE_DIR), so its scheduled child must accept precisely its derived
1303
- * owner name after the scheduler context restores the environment.
737
+ * AKM_BUNDLE_DIR), so its scheduled child — every row sets AKM_BUNDLE_DIR to
738
+ * the working stash it was synced from — must accept precisely its derived
739
+ * owner name.
1304
740
  *
1305
741
  * This is intentionally narrower than an unknown-bundle fallback: a configured
1306
742
  * source always wins, and an unconfigured selector is accepted only when it is
@@ -1328,31 +764,9 @@ function isPrimaryStashPath(candidate) {
1328
764
  }
1329
765
  return path.resolve(candidate) === primary;
1330
766
  }
1331
- /** Two bundle attributions match when both are the primary (undefined) or equal names. */
1332
- function sameBundle(a, b) {
1333
- return (a ?? undefined) === (b ?? undefined);
1334
- }
1335
- function installedEntryRunsTask(entry, id) {
1336
- const invocation = entry.invocation;
1337
- return invocation?.[0] === "task" && invocation[1] === "run" && invocation[2] === id;
1338
- }
1339
- function foreignScheduleMessage(id, existingTarget) {
1340
- const where = existingTarget === undefined ? "the default bundle" : `bundle "${existingTarget}"`;
1341
- return `Task id "${id}" is already scheduled from ${where}; rename the task or disable the existing one first.`;
1342
- }
1343
767
  function errorMessage(cause) {
1344
768
  return cause instanceof Error ? cause.message : String(cause);
1345
769
  }
1346
- /**
1347
- * Refuse to schedule an id already installed from a DIFFERENT bundle. Scheduler
1348
- * ids are the bare task id (never namespaced), so a single id can be active from
1349
- * only one bundle at a time — a collision is a hard error, not an auto-rename.
1350
- */
1351
- function assertNoForeignSchedule(entries, id, installTarget) {
1352
- const foreign = entries.find((entry) => entry.id === id && !sameBundle(entry.target, installTarget));
1353
- if (foreign)
1354
- throw new UsageError(foreignScheduleMessage(id, foreign.target), "RESOURCE_ALREADY_EXISTS");
1355
- }
1356
770
  /**
1357
771
  * Infer a JSON-Schema-subset `type` keyword from one `--params` value's
1358
772
  * runtime shape (spec docs/plans/specs/p4-deletions-closeout.md §3.2.6, row