plutonium 0.62.2 → 0.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/skills/plutonium/SKILL.md +44 -0
  3. data/.claude/skills/plutonium-app/SKILL.md +3 -3
  4. data/.claude/skills/plutonium-async-interactions/SKILL.md +191 -0
  5. data/.claude/skills/plutonium-auth/SKILL.md +36 -0
  6. data/.claude/skills/plutonium-behavior/SKILL.md +121 -24
  7. data/.claude/skills/plutonium-kanban/SKILL.md +17 -3
  8. data/.claude/skills/plutonium-resource/SKILL.md +259 -12
  9. data/.claude/skills/plutonium-tenancy/SKILL.md +32 -3
  10. data/.claude/skills/plutonium-ui/SKILL.md +115 -14
  11. data/.claude/skills/plutonium-wizard/SKILL.md +73 -4
  12. data/CHANGELOG.md +67 -0
  13. data/CLAUDE.md +87 -0
  14. data/Rakefile +34 -0
  15. data/SECURITY.md +1 -1
  16. data/app/assets/plutonium.css +1 -1
  17. data/app/assets/plutonium.js +685 -102
  18. data/app/assets/plutonium.js.map +4 -4
  19. data/app/assets/plutonium.min.js +53 -53
  20. data/app/assets/plutonium.min.js.map +4 -4
  21. data/app/views/rodauth/_login_form.html.erb +13 -0
  22. data/db/migrate/async_interactions/20260817000001_create_plutonium_async_runs.rb +170 -0
  23. data/docs/.vitepress/config.ts +81 -3
  24. data/docs/.vitepress/theme/blog.data.ts +44 -0
  25. data/docs/.vitepress/theme/components/BlogIndex.vue +87 -0
  26. data/docs/.vitepress/theme/components/BlogMeta.vue +47 -0
  27. data/docs/.vitepress/theme/components/HomeFeatureTour.vue +293 -0
  28. data/docs/.vitepress/theme/components/HomeHero.vue +3 -3
  29. data/docs/.vitepress/theme/components/HomeInTheBox.vue +8 -0
  30. data/docs/.vitepress/theme/components/HomeStopWriting.vue +1 -0
  31. data/docs/.vitepress/theme/components/HomeWhyPlutonium.vue +84 -0
  32. data/docs/.vitepress/theme/index.ts +8 -4
  33. data/docs/blog/association-inputs-post-signed-ids.md +70 -0
  34. data/docs/blog/fix-the-model-not-the-policy.md +122 -0
  35. data/docs/blog/fractional-ordering-runs-out-of-room.md +67 -0
  36. data/docs/blog/half-finished-forms-are-pii.md +76 -0
  37. data/docs/blog/index.md +13 -0
  38. data/docs/blog/interactions-are-presentation-objects.md +152 -0
  39. data/docs/blog/introducing-plutonium.md +253 -0
  40. data/docs/blog/jobs-are-not-permission-snapshots.md +100 -0
  41. data/docs/blog/plutonium-and-ai-agents.md +48 -0
  42. data/docs/blog/realtime-is-one-line-and-four-dependencies.md +70 -0
  43. data/docs/blog/two-forms-one-dom-id.md +69 -0
  44. data/docs/blog/whats-new-async-kanban-wizards.md +130 -0
  45. data/docs/getting-started/tutorial/04-authorization.md +12 -3
  46. data/docs/getting-started/tutorial/06-nested-resources.md +3 -1
  47. data/docs/getting-started/tutorial/07-author-portal.md +2 -2
  48. data/docs/guides/authentication.md +73 -0
  49. data/docs/guides/authorization.md +2 -0
  50. data/docs/guides/creating-packages.md +5 -3
  51. data/docs/guides/custom-actions.md +74 -12
  52. data/docs/guides/customizing-ui.md +9 -2
  53. data/docs/guides/index.md +1 -0
  54. data/docs/guides/kanban.md +7 -5
  55. data/docs/guides/nested-resources.md +11 -1
  56. data/docs/guides/performance.md +104 -0
  57. data/docs/guides/user-invites.md +1 -1
  58. data/docs/guides/wizards.md +9 -1
  59. data/docs/index.md +3 -3
  60. data/docs/public/images/home/tour-actions.png +0 -0
  61. data/docs/public/images/home/tour-async.png +0 -0
  62. data/docs/public/images/home/tour-kanban.png +0 -0
  63. data/docs/public/images/home/tour-tenancy.png +0 -0
  64. data/docs/public/images/home/tour-wizard.png +0 -0
  65. data/docs/public/images/reference/async-progress-page.png +0 -0
  66. data/docs/public/images/reference/async-running-banner.png +0 -0
  67. data/docs/public/templates/experimental.rb +34 -0
  68. data/docs/public/templates/pluton8.rb +14 -0
  69. data/docs/reference/app/portals.md +15 -3
  70. data/docs/reference/auth/accounts.md +19 -0
  71. data/docs/reference/behavior/async-interactions.md +295 -0
  72. data/docs/reference/behavior/controllers.md +17 -4
  73. data/docs/reference/behavior/index.md +7 -1
  74. data/docs/reference/behavior/interactions.md +152 -22
  75. data/docs/reference/configuration.md +5 -0
  76. data/docs/reference/index.md +1 -0
  77. data/docs/reference/kanban/dsl.md +7 -4
  78. data/docs/reference/kanban/index.md +1 -1
  79. data/docs/reference/kanban/positioning.md +26 -4
  80. data/docs/reference/positioning.md +568 -0
  81. data/docs/reference/resource/actions.md +97 -4
  82. data/docs/reference/resource/definition.md +181 -9
  83. data/docs/reference/tenancy/invites.md +1 -1
  84. data/docs/reference/tenancy/nested-resources.md +60 -2
  85. data/docs/reference/ui/assets.md +4 -0
  86. data/docs/reference/ui/components.md +57 -4
  87. data/docs/reference/ui/displays.md +20 -10
  88. data/docs/reference/ui/index.md +1 -1
  89. data/docs/reference/wizard/dsl.md +33 -0
  90. data/docs/reference/wizard/storage-config.md +1 -0
  91. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md +624 -0
  92. data/docs/superpowers/plans/2026-07-16-homepage-depth-upgrade.md.tasks.json +32 -0
  93. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md +1787 -0
  94. data/docs/superpowers/plans/2026-07-31-positioned-drag-and-drop.md.tasks.json +91 -0
  95. data/docs/superpowers/plans/2026-08-17-async-interactions.md +1414 -0
  96. data/docs/superpowers/plans/2026-08-17-async-interactions.md.tasks.json +66 -0
  97. data/docs/superpowers/specs/2026-07-16-homepage-depth-upgrade-design.md +111 -0
  98. data/docs/superpowers/specs/2026-07-17-action-html-attributes-design.md +124 -0
  99. data/docs/superpowers/specs/2026-07-31-positioned-drag-and-drop-design.md +506 -0
  100. data/docs/superpowers/specs/2026-08-17-async-interactions-design.md +185 -0
  101. data/gemfiles/postgres.gemfile.lock +85 -85
  102. data/gemfiles/rails_7.gemfile.lock +322 -140
  103. data/gemfiles/rails_8.0.gemfile.lock +125 -115
  104. data/gemfiles/rails_8.1.gemfile.lock +126 -116
  105. data/lib/generators/pu/async_interactions/install_generator.rb +111 -0
  106. data/lib/generators/pu/async_interactions/templates/app/controllers/async_runs_controller.rb.tt +15 -0
  107. data/lib/generators/pu/core/typespec/typespec_generator.rb +7 -4
  108. data/lib/generators/pu/invites/install_generator.rb +3 -3
  109. data/lib/generators/pu/invites/templates/packages/invites/app/views/layouts/invites/invitation.html.erb.tt +2 -2
  110. data/lib/generators/pu/lib/plutonium_generators/concerns/mounts_engines.rb +47 -2
  111. data/lib/generators/pu/lib/plutonium_generators/concerns/resource_registration.rb +41 -0
  112. data/lib/generators/pu/lite/litestream/litestream_generator.rb +1 -1
  113. data/lib/generators/pu/lite/solid_queue/solid_queue_generator.rb +1 -1
  114. data/lib/generators/pu/res/conn/conn_generator.rb +19 -39
  115. data/lib/generators/pu/res/conn/templates/app/controllers/resource_controller.rb.tt +4 -0
  116. data/lib/generators/pu/rodauth/templates/app/rodauth/account_rodauth_plugin.rb.tt +15 -6
  117. data/lib/generators/pu/rodauth/templates/app/rodauth/rodauth_plugin.rb.tt +7 -0
  118. data/lib/generators/pu/saas/welcome/templates/app/views/layouts/welcome.html.erb.tt +2 -2
  119. data/lib/generators/pu/wizards/install_generator.rb +78 -0
  120. data/lib/plutonium/action/base.rb +71 -9
  121. data/lib/plutonium/action/interactive.rb +9 -0
  122. data/lib/plutonium/attachments.rb +254 -0
  123. data/lib/plutonium/configuration.rb +82 -1
  124. data/lib/plutonium/core/controller.rb +50 -7
  125. data/lib/plutonium/core/controllers/authorizable.rb +16 -0
  126. data/lib/plutonium/core/controllers/entity_scoping.rb +12 -2
  127. data/lib/plutonium/definition/base.rb +51 -0
  128. data/lib/plutonium/definition/display_layout.rb +112 -0
  129. data/lib/plutonium/definition/index_views.rb +8 -7
  130. data/lib/plutonium/definition/input_aliases.rb +38 -0
  131. data/lib/plutonium/definition/page_widths.rb +65 -0
  132. data/lib/plutonium/definition/positioning.rb +126 -0
  133. data/lib/plutonium/definition/sorting.rb +17 -2
  134. data/lib/plutonium/helpers/turbo_helper.rb +7 -0
  135. data/lib/plutonium/interaction/README.md +61 -24
  136. data/lib/plutonium/interaction/async/configuration.rb +38 -0
  137. data/lib/plutonium/interaction/async/context.rb +419 -0
  138. data/lib/plutonium/interaction/async/executor.rb +422 -0
  139. data/lib/plutonium/interaction/async/job.rb +80 -0
  140. data/lib/plutonium/interaction/async/reap_job.rb +81 -0
  141. data/lib/plutonium/interaction/async/run.rb +394 -0
  142. data/lib/plutonium/interaction/async/run_definition.rb +155 -0
  143. data/lib/plutonium/interaction/async/run_policy.rb +86 -0
  144. data/lib/plutonium/interaction/base.rb +34 -7
  145. data/lib/plutonium/interaction/concerns/dispatchable.rb +518 -0
  146. data/lib/plutonium/interaction/concerns/scoping.rb +70 -9
  147. data/lib/plutonium/interaction/response/redirect.rb +11 -3
  148. data/lib/plutonium/kanban/board.rb +14 -0
  149. data/lib/plutonium/kanban/column.rb +4 -2
  150. data/lib/plutonium/kanban/dsl.rb +4 -1
  151. data/lib/plutonium/kanban/grouping.rb +9 -22
  152. data/lib/plutonium/kanban/positioning.rb +5 -65
  153. data/lib/plutonium/positioning/config.rb +94 -0
  154. data/lib/plutonium/positioning/model.rb +128 -0
  155. data/lib/plutonium/positioning.rb +25 -86
  156. data/lib/plutonium/railtie.rb +1 -0
  157. data/lib/plutonium/resource/controller.rb +118 -38
  158. data/lib/plutonium/resource/controllers/crud_actions/index_action.rb +32 -2
  159. data/lib/plutonium/resource/controllers/crud_actions.rb +30 -2
  160. data/lib/plutonium/resource/controllers/eager_loading.rb +87 -0
  161. data/lib/plutonium/resource/controllers/export_csv.rb +10 -1
  162. data/lib/plutonium/resource/controllers/kanban_actions.rb +53 -14
  163. data/lib/plutonium/resource/controllers/position_actions.rb +390 -0
  164. data/lib/plutonium/resource/controllers/presentable.rb +19 -13
  165. data/lib/plutonium/resource/controllers/queryable.rb +5 -1
  166. data/lib/plutonium/resource/controllers/wizard_actions.rb +21 -0
  167. data/lib/plutonium/resource/policy.rb +33 -0
  168. data/lib/plutonium/resource/query_object.rb +36 -0
  169. data/lib/plutonium/routing/mapper_extensions.rb +100 -8
  170. data/lib/plutonium/routing/route_set_extensions.rb +15 -1
  171. data/lib/plutonium/routing/wizard_registration.rb +4 -0
  172. data/lib/plutonium/testing/resource_policy.rb +6 -2
  173. data/lib/plutonium/ui/action_button.rb +12 -7
  174. data/lib/plutonium/ui/actions_dropdown.rb +1 -1
  175. data/lib/plutonium/ui/block.rb +21 -1
  176. data/lib/plutonium/ui/breadcrumbs.rb +187 -55
  177. data/lib/plutonium/ui/component/methods.rb +5 -0
  178. data/lib/plutonium/ui/component/positionable.rb +112 -0
  179. data/lib/plutonium/ui/component/resolves_tags.rb +57 -0
  180. data/lib/plutonium/ui/component/section.rb +185 -0
  181. data/lib/plutonium/ui/display/base.rb +13 -1
  182. data/lib/plutonium/ui/display/components/formatted_value.rb +26 -0
  183. data/lib/plutonium/ui/display/components/section.rb +18 -0
  184. data/lib/plutonium/ui/display/resource.rb +141 -22
  185. data/lib/plutonium/ui/display/theme.rb +20 -1
  186. data/lib/plutonium/ui/export_button.rb +1 -1
  187. data/lib/plutonium/ui/form/base.rb +8 -7
  188. data/lib/plutonium/ui/form/components/intl_tel_input.rb +1 -1
  189. data/lib/plutonium/ui/form/components/section.rb +7 -62
  190. data/lib/plutonium/ui/form/components/uppy.rb +12 -1
  191. data/lib/plutonium/ui/form/concerns/renders_nested_resource_fields.rb +16 -3
  192. data/lib/plutonium/ui/form/concerns/renders_structured_inputs.rb +5 -1
  193. data/lib/plutonium/ui/form/query.rb +2 -4
  194. data/lib/plutonium/ui/form/resource.rb +92 -15
  195. data/lib/plutonium/ui/form/theme.rb +17 -0
  196. data/lib/plutonium/ui/form/wizard.rb +25 -1
  197. data/lib/plutonium/ui/grid/card.rb +79 -11
  198. data/lib/plutonium/ui/grid/resource.rb +47 -5
  199. data/lib/plutonium/ui/interaction/async/run_progress.rb +227 -0
  200. data/lib/plutonium/ui/interaction/async/running_banner.rb +65 -0
  201. data/lib/plutonium/ui/kanban/card.rb +2 -1
  202. data/lib/plutonium/ui/kanban/column.rb +12 -6
  203. data/lib/plutonium/ui/kanban/resource.rb +6 -7
  204. data/lib/plutonium/ui/layout/base.rb +10 -3
  205. data/lib/plutonium/ui/nav_grid_menu.rb +1 -0
  206. data/lib/plutonium/ui/page/base.rb +19 -0
  207. data/lib/plutonium/ui/page/edit.rb +4 -1
  208. data/lib/plutonium/ui/page/index.rb +69 -18
  209. data/lib/plutonium/ui/page/interactive_action.rb +5 -1
  210. data/lib/plutonium/ui/page/new.rb +4 -1
  211. data/lib/plutonium/ui/page/show.rb +27 -10
  212. data/lib/plutonium/ui/page/wizard.rb +10 -1
  213. data/lib/plutonium/ui/page/wizard_chooser.rb +36 -11
  214. data/lib/plutonium/ui/page_width.rb +58 -0
  215. data/lib/plutonium/ui/table/base.rb +34 -1
  216. data/lib/plutonium/ui/table/components/attachment.rb +1 -1
  217. data/lib/plutonium/ui/table/components/bulk_actions_toolbar.rb +32 -8
  218. data/lib/plutonium/ui/table/components/drag_handle.rb +120 -0
  219. data/lib/plutonium/ui/table/components/filter_form.rb +1 -4
  220. data/lib/plutonium/ui/table/components/filter_pills.rb +1 -1
  221. data/lib/plutonium/ui/table/components/row_actions_dropdown.rb +1 -1
  222. data/lib/plutonium/ui/table/resource.rb +50 -5
  223. data/lib/plutonium/ui/table/theme.rb +59 -2
  224. data/lib/plutonium/ui/wizard/review.rb +4 -2
  225. data/lib/plutonium/ui/wizard/summary_display.rb +42 -14
  226. data/lib/plutonium/version.rb +1 -1
  227. data/lib/plutonium/wizard/attachments.rb +32 -197
  228. data/lib/plutonium/wizard/base.rb +6 -1
  229. data/lib/plutonium/wizard/configuration.rb +12 -0
  230. data/lib/plutonium/wizard/controller.rb +14 -0
  231. data/lib/plutonium/wizard/driving.rb +99 -15
  232. data/lib/plutonium/wizard/dsl.rb +23 -0
  233. data/lib/plutonium/wizard/resume.rb +127 -49
  234. data/lib/plutonium/wizard/runner.rb +46 -2
  235. data/lib/plutonium/wizard/step_adapter.rb +1 -1
  236. data/lib/plutonium/wizard/sweep_job.rb +16 -0
  237. data/lib/plutonium.rb +21 -0
  238. data/lib/rodauth/features/session_isolation.rb +92 -0
  239. data/lib/rodauth/plugins.rb +1 -0
  240. data/package.json +2 -1
  241. data/plutonium.gemspec +29 -11
  242. data/src/css/components.css +89 -1
  243. data/src/css/slim_select.css +20 -0
  244. data/src/js/controllers/breadcrumbs_controller.js +112 -0
  245. data/src/js/controllers/bulk_actions_controller.js +10 -2
  246. data/src/js/controllers/kanban_controller.js +30 -21
  247. data/src/js/controllers/positioned_controller.js +452 -0
  248. data/src/js/controllers/register_controllers.js +6 -0
  249. data/src/js/controllers/resource_drop_down_controller.js +5 -0
  250. data/src/js/controllers/run_progress_controller.js +73 -0
  251. data/src/js/drag/sortable.js +186 -0
  252. data/yarn.lock +108 -63
  253. metadata +103 -16
  254. data/docs/.vitepress/theme/components/HomeAudienceSplit.vue +0 -53
  255. data/docs/.vitepress/theme/components/HomePillars.vue +0 -42
@@ -34,6 +34,19 @@
34
34
  <% end %>
35
35
  </div>
36
36
  <%= render "password_visibility" %>
37
+ <%# Only rendered when the config enables `remember`. The box's value is
38
+ `remember_remember_param_value`, which is what the config's after_login hook
39
+ compares against. `include_hidden: false` keeps Rails' default "0" off the
40
+ wire when unticked - belt-and-braces next to that value comparison, but it
41
+ also keeps this form correct for a config using a bare presence check. %>
42
+ <% if rodauth.features.include?(:remember) %>
43
+ <div class="flex items-center space-x-2">
44
+ <%= form.check_box rodauth.remember_param,
45
+ {id: "remember", class: "pu-checkbox", include_hidden: false},
46
+ rodauth.remember_remember_param_value %>
47
+ <%= form.label "remember", rodauth.remember_remember_label, class: "text-sm text-[var(--pu-text)]" %>
48
+ </div>
49
+ <% end %>
37
50
  <% end %>
38
51
  <%= form.submit rodauth.login_button, class: "w-full pu-btn pu-btn-md pu-btn-primary" %>
39
52
  <% end %>
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreatePlutoniumAsyncRuns < ActiveRecord::Migration[7.2]
4
+ def change
5
+ create_table :plutonium_async_runs do |t|
6
+ # STI discriminator — the author's Run subclass.
7
+ t.string :type, null: false
8
+ t.string :state, null: false, default: "pending" # pending | running | completed | failed
9
+
10
+ # JSON payloads are jsonb, matching plutonium_wizard_sessions: Postgres
11
+ # hosts get equality and GIN indexing (plain json has neither), and SQLite
12
+ # hosts get the type via PLUTONIUM_SQLITE_TYPE_ALIASES, which aliases
13
+ # jsonb -> json. Changing a column type post-release costs every host app a
14
+ # migration, so pick the queryable one now.
15
+ #
16
+ # The dispatching interaction's validated inputs.
17
+ t.jsonb :options, null: false, default: {}
18
+
19
+ # Targets. target_type is a REAL column, not JSON: the index feature
20
+ # queries "runs for this resource", which cannot be indexed out of a
21
+ # JSON array. Ids are re-resolved through the policy scope at perform time.
22
+ t.string :target_type
23
+ t.jsonb :target_ids, null: false, default: []
24
+
25
+ # Who started it, and in which tenant — the two halves of the policy
26
+ # context, which Plutonium authorizes on as (user, entity_scope).
27
+ #
28
+ # The initiator is ALWAYS required. There is no such thing as a run with
29
+ # no user to authorize as, and a nullable initiator would inevitably come
30
+ # to mean "unauthenticated/unscoped" — the fail-OPEN case.
31
+ #
32
+ # The scoped entity is nullable BY DESIGN: it is present only when the
33
+ # run was launched in an entity-scoped portal, and nil for an un-scoped
34
+ # one (same as plutonium_wizard_sessions.scope_type/scope_id). nil here
35
+ # means "no tenant", NOT "tenant unknown". Code rebuilding the policy
36
+ # context must handle nil explicitly rather than assume a tenant is
37
+ # present — this schema offers no non-null guarantee to lean on.
38
+ #
39
+ # *_id is string-typed to accommodate bigint or uuid host primary keys,
40
+ # matching plutonium_wizard_sessions.
41
+ t.string :initiator_type, null: false
42
+ t.string :initiator_id, null: false
43
+ t.string :scoped_entity_type
44
+ t.string :scoped_entity_id
45
+
46
+ # The third half of the policy context: WHICH policy applied.
47
+ #
48
+ # Policy lookup in a controller passes the controller's own module nesting
49
+ # as the namespace (ActionPolicy::Behaviours::Namespaced), which is how a
50
+ # portal gets its own policy — StorefrontPortal::Blogging::PostPolicy
51
+ # rather than Blogging::PostPolicy. A job has no controller and therefore
52
+ # no namespace to derive, so without this the lookup falls back to the base
53
+ # policy and every narrowing the portal applied is silently lost. Nullable
54
+ # because a top-level dispatch legitimately has no namespace; nil here
55
+ # means "top level", NOT "namespace unknown".
56
+ #
57
+ # Stored as the module's NAME (e.g. "StorefrontPortal"), constantized at
58
+ # perform time — ActionPolicy.lookup requires a Module and raises on a
59
+ # String.
60
+ t.string :authorization_namespace
61
+
62
+ # The nested-route PARENT, and the association the child hangs off it —
63
+ # /orgs/1/posts/5/comments dispatches with (Post#5, :comments).
64
+ #
65
+ # Both halves or neither: Policy#default_relation_scope raises on one
66
+ # without the other, and it is the pair that names a scope.
67
+ #
68
+ # The third input the policy authorizes on, alongside the initiator and the
69
+ # tenant. Without it, Policy#default_relation_scope cannot take its parent
70
+ # branch and falls through to entity scoping — so perform time re-derives
71
+ # targets under the TENANT where dispatch used the parent, a wider answer
72
+ # than the one the initiator was shown. It also leaves any host predicate
73
+ # reading +parent+ looking at nil, which refuses every target with a reason
74
+ # that names the wrong thing.
75
+ #
76
+ # Nullable, like scoped_entity and for the same reason: most routes are not
77
+ # nested. nil means "not a nested dispatch", NOT "parent unknown" — see
78
+ # AsyncRuns::Context#verify_subjects!, which tells those apart by the _type
79
+ # column exactly as it does for the tenant.
80
+ t.string :parent_type
81
+ t.string :parent_id
82
+ t.string :parent_association
83
+
84
+ # The policy actually resolved AT DISPATCH, e.g.
85
+ # "StorefrontPortal::Blogging::PostPolicy". An ASSERTION, not an input: at
86
+ # perform time the policy is resolved from the namespace above and then
87
+ # checked against this. The namespace fixes today's lookup, but a policy
88
+ # renamed, deleted or re-parented between enqueue and perform would resolve
89
+ # to something else — the same silent widening, just moved later in time.
90
+ # Recording what we expected turns that into a loud failure.
91
+ #
92
+ # NOT named `policy_class`: ActionPolicy's lookup chain probes
93
+ # `record.policy_class` before inferring from the class name, so a column
94
+ # of that name would make ActionPolicy.lookup(run) return this String
95
+ # instead of a policy — breaking authorization of run records themselves.
96
+ t.string :policy_class_name
97
+
98
+ # The policy PREDICATE dispatch checked, e.g. "archive?".
99
+ #
100
+ # The class above says which policy; this says which question to ask it.
101
+ # Without it a run re-checks only VISIBILITY (the relation scope) and not
102
+ # PERMISSION: an initiator whose archive? flipped to false after enqueue
103
+ # would still resolve every target and the work would proceed. Dispatch
104
+ # derives this from the action name and checks it PER RECORD — see
105
+ # Plutonium::Resource::Controllers::InteractiveActions#authorize_interactive_bulk_action!
106
+ # — and perform must reproduce exactly that.
107
+ #
108
+ # Nullable ONLY because opaque (untargeted) work has no per-record
109
+ # predicate to check. nil means "no per-target predicate", and for a
110
+ # TARGETED run that is refused rather than read as "allow everything" —
111
+ # the fail-open shape again.
112
+ t.string :policy_action
113
+
114
+ # Counts. Both nil for opaque (untargeted) work — the progress UI reads
115
+ # nil as "indeterminate" rather than 0%.
116
+ t.integer :progress_total
117
+ t.integer :progress_done, null: false, default: 0
118
+
119
+ # Per-target execution failures, appended as the run proceeds — hence an
120
+ # array default. A run may fail on some targets and still complete.
121
+ t.jsonb :errors_log, null: false, default: []
122
+
123
+ t.datetime :started_at
124
+ t.datetime :finished_at
125
+
126
+ # Set only once a job actually picks the run up (AsyncRuns::Executor#claim!)
127
+ # and bumped on every write that represents real progress thereafter —
128
+ # NOT at create time, and not the same thing as updated_at, which bumps
129
+ # on any write to the row. nil means "never picked up"; AsyncRuns::ReapJob
130
+ # falls back to created_at for that case.
131
+ t.datetime :last_activity_at
132
+
133
+ # Target ids already dispositioned (succeeded or failed), so a run
134
+ # resumed after an interruption can skip work it already did instead of
135
+ # either replaying it or refusing to resume at all. See AsyncRuns::Executor.
136
+ t.jsonb :handled_target_ids, null: false, default: []
137
+
138
+ # Rails' standard optimistic-locking column (same convention as
139
+ # plutonium_wizard_sessions.lock_version) — bumped by AsyncRuns::Executor#claim!
140
+ # on every successful claim, on top of the ordinary automatic bump every
141
+ # +save!+/+update!+ already gets. ReapJob's resume is a TIME heuristic, not
142
+ # a true lease (see AsyncRuns::ReapJob): it can hand a run to a second executor
143
+ # while the first is still mid-batch. Without this column that race
144
+ # silently double-applies AND can lose one side's progress write; with it,
145
+ # the superseded executor's next write raises ActiveRecord::StaleObjectError
146
+ # (see AsyncRuns::Executor#call), which is treated as "no longer mine" rather
147
+ # than a target or run failure.
148
+ t.integer :lock_version, null: false, default: 0
149
+
150
+ t.timestamps
151
+
152
+ t.index [:target_type, :state], name: "idx_pu_runs_on_target_and_state"
153
+ t.index [:initiator_type, :initiator_id], name: "idx_pu_runs_on_initiator"
154
+ t.index [:scoped_entity_type, :scoped_entity_id], name: "idx_pu_runs_on_scoped_entity"
155
+ # AsyncRuns::ReapJob's stalled scan filters on state with no target_type
156
+ # predicate, so it cannot use idx_pu_runs_on_target_and_state above (wrong
157
+ # leading column) — without this, the scan degrades to a full-table scan as
158
+ # completed/failed history accumulates. Low-cardinality but selective for
159
+ # this query: the in-progress rows are a vanishing fraction of a mature
160
+ # table, which is exactly the case that needs the index.
161
+ #
162
+ # state ALONE, deliberately. The scan's other predicate is
163
+ # COALESCE(last_activity_at, created_at) < ? (see Run.stalled) — an
164
+ # expression, so a trailing last_activity_at column could not be used for
165
+ # the range at all, and created_at is not in the index either, so it buys
166
+ # no index-only scan. It would be write cost for nothing.
167
+ t.index :state, name: "idx_pu_runs_on_state"
168
+ end
169
+ end
170
+ end
@@ -1,8 +1,28 @@
1
- import { defineConfig } from "vitepress"
1
+ import { defineConfig, createContentLoader, type SiteConfig, type HeadConfig } from "vitepress"
2
2
  import { withMermaid } from "vitepress-plugin-mermaid";
3
3
  import llmstxt from "vitepress-plugin-llms";
4
+ import { Feed } from "feed";
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
4
8
 
5
9
  const base = "/plutonium-core/"
10
+ // Origin + base, with no trailing slash — content-loader URLs already start with one.
11
+ const hostname = "https://radioactive-labs.github.io/plutonium-core"
12
+
13
+ // Whether the blog has anything a reader could actually open: a post that is not
14
+ // a draft and whose date has arrived. Same rule the loader and the feed apply, so
15
+ // the nav entry and the RSS advert never point at an empty page.
16
+ const hasPublishedPost = fs
17
+ .readdirSync(fileURLToPath(new URL("../blog", import.meta.url)))
18
+ .filter((f) => f.endsWith(".md") && f !== "index.md")
19
+ .some((f) => {
20
+ const raw = fs.readFileSync(fileURLToPath(new URL(`../blog/${f}`, import.meta.url)), "utf8")
21
+ const frontmatter = raw.split("---")[1] ?? ""
22
+ if (/^draft:\s*true\s*$/m.test(frontmatter)) return false
23
+ const date = frontmatter.match(/^date:\s*(.+)$/m)?.[1]?.trim()
24
+ return !!date && +new Date(date) <= Date.now()
25
+ })
6
26
 
7
27
  // https://vitepress.dev/reference/site-config
8
28
  export default defineConfig(withMermaid({
@@ -20,6 +40,9 @@ export default defineConfig(withMermaid({
20
40
  ["meta", { name: "twitter:title", content: "Plutonium - Build Production-Ready Rails Apps in Minutes" }],
21
41
  ["meta", { name: "twitter:description", content: "Build production-ready Rails applications in minutes, not days. Convention-driven, fully customizable. Built for the AI era." }],
22
42
  ["meta", { name: "twitter:image", content: "https://radioactive-labs.github.io/plutonium-core/og-image.png" }],
43
+ ...(hasPublishedPost
44
+ ? ([["link", { rel: "alternate", type: "application/rss+xml", title: "Plutonium Blog", href: `${hostname}/blog/feed.rss` }]] as HeadConfig[])
45
+ : []),
23
46
  ],
24
47
  ignoreDeadLinks: 'localhostLinks',
25
48
  srcExclude: ['superpowers/**'],
@@ -37,6 +60,7 @@ export default defineConfig(withMermaid({
37
60
  "getting-started/index.md",
38
61
  "guides/index.md",
39
62
  "reference/index.md",
63
+ "blog/index.md",
40
64
  ],
41
65
  }),
42
66
  ],
@@ -52,7 +76,9 @@ export default defineConfig(withMermaid({
52
76
  { text: "Getting Started", link: "/getting-started/" },
53
77
  { text: "Guides", link: "/guides/" },
54
78
  { text: "Reference", link: "/reference/" },
55
- { text: "For AI Agents", link: "/ai" }
79
+ ...(hasPublishedPost ? [{ text: "Blog", link: "/blog/" }] : []),
80
+ { text: "For AI Agents", link: "/ai" },
81
+ { text: "Radioactive Labs", link: "https://radioactive-labs.github.io/" }
56
82
  ],
57
83
  sidebar: {
58
84
  '/getting-started/': [
@@ -122,6 +148,8 @@ export default defineConfig(withMermaid({
122
148
  text: "Quality",
123
149
  items: [
124
150
  { text: "Testing", link: "/guides/testing" },
151
+ { text: "Performance", link: "/guides/performance" },
152
+ { text: "Troubleshooting", link: "/guides/troubleshooting" },
125
153
  ]
126
154
  }
127
155
  ],
@@ -153,6 +181,7 @@ export default defineConfig(withMermaid({
153
181
  { text: "Definition", link: "/reference/resource/definition" },
154
182
  { text: "Query", link: "/reference/resource/query" },
155
183
  { text: "Actions", link: "/reference/resource/actions" },
184
+ { text: "Positioning & drag-to-reorder", link: "/reference/positioning" },
156
185
  { text: "CSV Export", link: "/reference/resource/export" },
157
186
  ]
158
187
  },
@@ -164,6 +193,7 @@ export default defineConfig(withMermaid({
164
193
  { text: "Controllers", link: "/reference/behavior/controllers" },
165
194
  { text: "Policies", link: "/reference/behavior/policies" },
166
195
  { text: "Interactions", link: "/reference/behavior/interactions" },
196
+ { text: "Async Interactions", link: "/reference/behavior/async-interactions" },
167
197
  ]
168
198
  },
169
199
  {
@@ -235,8 +265,56 @@ export default defineConfig(withMermaid({
235
265
  ],
236
266
  footer: {
237
267
  message: 'Released under the MIT License.',
238
- copyright: 'Copyright © 2024-present Stefan Froelich'
268
+ copyright: 'Copyright © 2024-present Stefan Froelich · <a href="https://radioactive-labs.github.io/" target="_blank" rel="noopener">Radioactive Labs</a>'
239
269
  }
240
270
  },
241
271
  cleanUrls: true,
272
+ async buildEnd(config: SiteConfig) {
273
+ const feed = new Feed({
274
+ title: "Plutonium",
275
+ description: "Release notes, design notes, and what's new in Plutonium.",
276
+ id: `${hostname}/blog/`,
277
+ link: `${hostname}/blog/`,
278
+ language: "en",
279
+ image: `${hostname}/og-image.png`,
280
+ favicon: `${hostname}/favicon.ico`,
281
+ copyright: "Copyright © 2024-present Stefan Froelich · Radioactive Labs",
282
+ })
283
+
284
+ const posts = await createContentLoader("blog/*.md", { render: true }).load()
285
+
286
+ posts
287
+ // Build-time cutoff: a feed cannot re-filter itself later, so a post dated
288
+ // ahead stays out until the next build after its date.
289
+ .filter(({ frontmatter }) => frontmatter.date && !frontmatter.draft)
290
+ .filter(({ frontmatter }) => +new Date(frontmatter.date) <= Date.now())
291
+ .sort((a, b) => +new Date(b.frontmatter.date) - +new Date(a.frontmatter.date))
292
+ .forEach(({ url, html, frontmatter }) => {
293
+ const link = `${hostname}${url}`
294
+ feed.addItem({
295
+ title: frontmatter.title,
296
+ id: link,
297
+ link,
298
+ description: frontmatter.description,
299
+ content: absolutize(html ?? "", link),
300
+ author: frontmatter.author ? [{ name: frontmatter.author }] : undefined,
301
+ category: frontmatter.tags?.map((name: string) => ({ name })),
302
+ date: new Date(frontmatter.date),
303
+ })
304
+ })
305
+
306
+ fs.mkdirSync(path.join(config.outDir, "blog"), { recursive: true })
307
+ fs.writeFileSync(path.join(config.outDir, "blog", "feed.rss"), feed.rss2())
308
+ },
242
309
  }))
310
+
311
+ // A feed reader has no page to resolve links against, so everything the renderer
312
+ // left relative has to be made absolute before it ships.
313
+ function absolutize(html: string, link: string): string {
314
+ return html
315
+ // The renderer emits .html even though the site serves clean URLs.
316
+ .replace(new RegExp(`(="${base}[^"]*?)\\.html(?=["#])`, "g"), "$1")
317
+ .replaceAll(`="${base}`, `="${hostname}/`)
318
+ // Bare fragments would point at whatever page the reader happens to be showing.
319
+ .replaceAll('href="#', `href="${link}#`)
320
+ }
@@ -0,0 +1,44 @@
1
+ import { createContentLoader } from "vitepress"
2
+
3
+ export interface Post {
4
+ title: string
5
+ url: string
6
+ description: string
7
+ author?: string
8
+ tags: string[]
9
+ date: { time: number; string: string }
10
+ }
11
+
12
+ declare const data: Post[]
13
+ export { data }
14
+
15
+ export default createContentLoader("blog/*.md", {
16
+ transform(raw): Post[] {
17
+ return raw
18
+ // The section index has no date: that's what separates it from a post.
19
+ .filter(({ frontmatter }) => frontmatter.date && !frontmatter.draft)
20
+ // Dated ahead means unpublished. The cutoff is applied at BUILD time, so a
21
+ // post goes live on the first build after its date, not on the date itself.
22
+ .filter(({ frontmatter }) => +new Date(frontmatter.date) <= Date.now())
23
+ .map(({ url, frontmatter }) => ({
24
+ title: frontmatter.title,
25
+ url,
26
+ description: frontmatter.description ?? "",
27
+ author: frontmatter.author,
28
+ tags: frontmatter.tags ?? [],
29
+ date: formatDate(frontmatter.date),
30
+ }))
31
+ .sort((a, b) => b.date.time - a.date.time)
32
+ },
33
+ })
34
+
35
+ function formatDate(raw: string | Date): Post["date"] {
36
+ const date = new Date(raw)
37
+ // Frontmatter dates parse as UTC midnight; nudge to midday so the rendered
38
+ // day doesn't slip backwards for readers behind UTC.
39
+ date.setUTCHours(12)
40
+ return {
41
+ time: +date,
42
+ string: date.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" }),
43
+ }
44
+ }
@@ -0,0 +1,87 @@
1
+ <template>
2
+ <section class="pu-section bi-section">
3
+ <div class="pu-section-inner">
4
+ <div class="pu-eyebrow">{{ eyebrow }}</div>
5
+ <h1 class="bi-h1">{{ title }}</h1>
6
+ <p class="bi-lede">{{ lede }}</p>
7
+
8
+ <a class="bi-rss" :href="withBase('/blog/feed.rss')">
9
+ <IconRss :size="15" :stroke-width="2" /> Subscribe via RSS
10
+ </a>
11
+
12
+ <div class="bi-list">
13
+ <a v-for="post in posts" :key="post.url" :href="withBase(post.url)" class="bi-post">
14
+ <time class="bi-date" :datetime="new Date(post.date.time).toISOString()">
15
+ {{ post.date.string }}
16
+ </time>
17
+ <span class="bi-body">
18
+ <span class="bi-title">{{ post.title }}</span>
19
+ <span v-if="post.description" class="bi-desc">{{ post.description }}</span>
20
+ <span v-if="post.tags.length" class="bi-tags">
21
+ <span v-for="tag in post.tags" :key="tag" class="bi-tag">{{ tag }}</span>
22
+ </span>
23
+ </span>
24
+ <IconArrowRight class="bi-arrow" :size="16" :stroke-width="2" />
25
+ </a>
26
+ </div>
27
+
28
+ <p v-if="!posts.length" class="bi-empty">No posts yet.</p>
29
+ </div>
30
+ </section>
31
+ </template>
32
+
33
+ <script setup>
34
+ import { withBase } from "vitepress"
35
+ import { IconArrowRight, IconRss } from "@tabler/icons-vue"
36
+ import { data as posts } from "../blog.data"
37
+
38
+ defineProps({
39
+ eyebrow: { type: String, default: "Blog" },
40
+ title: { type: String, required: true },
41
+ lede: { type: String, required: true },
42
+ })
43
+ </script>
44
+
45
+ <style scoped>
46
+ .bi-section { padding: 64px 24px 96px; }
47
+ .bi-h1 { font-size: 36px; letter-spacing: -0.025em; margin: 0 0 12px; color: var(--pu-text); }
48
+ .bi-lede { font-size: 16px; color: var(--pu-text-muted); max-width: 640px; margin: 0 0 20px; line-height: 1.55; }
49
+
50
+ .bi-rss {
51
+ display: inline-flex; align-items: center; gap: 6px; margin-bottom: 40px;
52
+ font-size: 13px; font-weight: 500; color: var(--pu-text-muted); text-decoration: none;
53
+ }
54
+ .bi-rss:hover { color: var(--pu-accent); }
55
+
56
+ .bi-list { border-left: 2px solid var(--pu-accent); padding-left: 24px; max-width: 760px; }
57
+ .bi-post {
58
+ display: flex; gap: 20px; align-items: flex-start;
59
+ padding: 18px 0; border-bottom: 1px solid var(--pu-border-soft);
60
+ color: var(--pu-text); text-decoration: none;
61
+ }
62
+ .bi-post:last-child { border-bottom: none; }
63
+ .bi-post:hover .bi-title { color: var(--pu-accent); }
64
+ .bi-post:hover .bi-arrow { transform: translateX(2px); color: var(--pu-accent); }
65
+
66
+ .bi-date {
67
+ flex-shrink: 0; width: 124px; padding-top: 2px;
68
+ font-size: 12.5px; color: var(--pu-text-faint); font-variant-numeric: tabular-nums;
69
+ }
70
+ .bi-body { display: flex; flex-direction: column; gap: 4px; flex: 1; }
71
+ .bi-title { font-weight: 600; font-size: 16px; letter-spacing: -0.01em; }
72
+ .bi-desc { font-size: 13.5px; color: var(--pu-text-muted); line-height: 1.5; }
73
+ .bi-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
74
+ .bi-tag {
75
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: 600;
76
+ color: var(--pu-text-faint); border: 1px solid var(--pu-border-soft);
77
+ border-radius: 3px; padding: 1px 6px;
78
+ }
79
+ .bi-arrow { color: var(--pu-text-faint); transition: transform 0.15s ease, color 0.15s ease; flex-shrink: 0; margin-top: 4px; }
80
+ .bi-empty { color: var(--pu-text-muted); font-size: 14px; }
81
+
82
+ @media (max-width: 640px) {
83
+ .bi-post { flex-direction: column; gap: 6px; }
84
+ .bi-date { width: auto; padding-top: 0; }
85
+ .bi-arrow { display: none; }
86
+ }
87
+ </style>
@@ -0,0 +1,47 @@
1
+ <template>
2
+ <div class="bm">
3
+ <div class="bm-line">
4
+ <time v-if="dateString" :datetime="isoDate">{{ dateString }}</time>
5
+ <span v-if="frontmatter.author" class="bm-sep">·</span>
6
+ <span v-if="frontmatter.author">{{ frontmatter.author }}</span>
7
+ </div>
8
+ <a class="bm-back" :href="withBase('/blog/')">
9
+ <IconArrowLeft :size="14" :stroke-width="2" /> All posts
10
+ </a>
11
+ </div>
12
+ </template>
13
+
14
+ <script setup>
15
+ import { computed } from "vue"
16
+ import { useData, withBase } from "vitepress"
17
+ import { IconArrowLeft } from "@tabler/icons-vue"
18
+
19
+ const { frontmatter } = useData()
20
+
21
+ // Frontmatter dates arrive as a Date (YAML-parsed) or a string, depending on quoting.
22
+ const parsed = computed(() => (frontmatter.value.date ? new Date(frontmatter.value.date) : null))
23
+ const isoDate = computed(() => parsed.value?.toISOString())
24
+ const dateString = computed(() =>
25
+ parsed.value?.toLocaleDateString("en-US", {
26
+ year: "numeric", month: "long", day: "numeric", timeZone: "UTC",
27
+ })
28
+ )
29
+ </script>
30
+
31
+ <style scoped>
32
+ .bm {
33
+ margin: 0 0 40px;
34
+ padding-bottom: 20px;
35
+ border-bottom: 1px solid var(--pu-border-soft);
36
+ }
37
+ .bm-line {
38
+ font-size: 14px; color: var(--pu-text-muted);
39
+ display: flex; align-items: center; gap: 7px;
40
+ }
41
+ .bm-sep { color: var(--pu-text-faint); }
42
+ .bm-back {
43
+ display: inline-flex; align-items: center; gap: 5px; margin-top: 14px;
44
+ font-size: 13px; font-weight: 500; color: var(--pu-accent); text-decoration: none;
45
+ }
46
+ .bm-back:hover { text-decoration: underline; }
47
+ </style>