silas 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +145 -0
  3. data/DEPLOY.md +111 -0
  4. data/README.md +87 -256
  5. data/app/helpers/silas/inbox/trace_helper.rb +29 -6
  6. data/app/models/concerns/silas/inbox/broadcastable.rb +12 -0
  7. data/app/views/layouts/silas/inbox.html.erb +89 -30
  8. data/app/views/silas/inbox/invocations/_approval_card.html.erb +11 -2
  9. data/app/views/silas/inbox/invocations/_invocation.html.erb +17 -5
  10. data/app/views/silas/inbox/sessions/_cost.html.erb +3 -1
  11. data/app/views/silas/inbox/sessions/_row.html.erb +14 -0
  12. data/app/views/silas/inbox/sessions/index.html.erb +16 -15
  13. data/app/views/silas/inbox/sessions/show.html.erb +10 -1
  14. data/app/views/silas/inbox/turns/_header.html.erb +31 -25
  15. data/app/views/silas/inbox/turns/_turn.html.erb +5 -3
  16. data/docs/agents.md +81 -0
  17. data/docs/budgets.md +67 -0
  18. data/docs/cancellation.md +41 -0
  19. data/docs/channels.md +290 -0
  20. data/docs/configuration.md +106 -0
  21. data/docs/connections.md +58 -0
  22. data/docs/conventions.md +161 -0
  23. data/docs/evals.md +95 -0
  24. data/docs/guarantees.md +76 -0
  25. data/docs/inbox-and-api.md +91 -0
  26. data/docs/memory.md +35 -0
  27. data/docs/sandbox.md +44 -0
  28. data/docs/tools.md +77 -0
  29. data/docs/tutorial.md +272 -0
  30. data/docs/vs-eve.md +93 -0
  31. data/docs/why-silas.md +87 -0
  32. data/lib/generators/silas/install/install_generator.rb +9 -0
  33. data/lib/generators/silas/install/templates/claude_skill.md +136 -0
  34. data/lib/generators/silas/install/templates/ruby_llm.rb +4 -1
  35. data/lib/silas/connection.rb +16 -0
  36. data/lib/silas/eval/dsl.rb +7 -2
  37. data/lib/silas/version.rb +1 -1
  38. metadata +22 -1
data/README.md CHANGED
@@ -1,295 +1,126 @@
1
- # Your Rails app is already an agent runtime.
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/danielstpaul/silas/main/brand/silas-wordmark.svg">
4
+ <img src="https://raw.githubusercontent.com/danielstpaul/silas/main/brand/silas-wordmark-light.svg" alt="silas" width="150">
5
+ </picture>
6
+ </p>
2
7
 
3
- **Silas** turns the Rails app you already run into a durable AI-agent runtime.
4
- Active Job Continuations, Solid Queue, and one Postgres ledger table make every
5
- turn survive `kill -9` and resume from the last completed step — with a live
6
- operator inbox at `/silas/inbox` and park-at-zero human-in-the-loop approvals
7
- holding the big levers. No new service, no managed platform, no per-run meter:
8
- the durable stack is already booted inside your app. The only new surface is the
9
- `app/agent/` directory below.
8
+ <p align="center">
9
+ <a href="https://rubygems.org/gems/silas"><img src="https://img.shields.io/gem/v/silas" alt="Gem"></a>
10
+ <a href="https://github.com/danielstpaul/silas/actions/workflows/ci.yml"><img src="https://github.com/danielstpaul/silas/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
11
+ <a href="https://github.com/danielstpaul/silas/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License: MIT"></a>
12
+ </p>
10
13
 
11
- Honestly early and honestly narrow: **v0.1, one maintainer, zero external
12
- users**, durability proven by an in-repo `kill -9` chaos harness (100/100, zero
13
- duplicate effects, byte-identical replay), and scoped to **trusted code you write
14
- yourself** by default for untrusted or model-generated code, drop in the
15
- companion gem [hermetic](https://github.com/danielstpaul/hermetic) (gVisor /
16
- Firecracker / hosted sandboxes behind one call, see below). The full pitch
17
- and the honest caveats: [Why Silas](docs/why-silas.md) ·
18
- [Silas vs eve](docs/vs-eve.md).
14
+ Silas is a Rails-native framework for durable AI agents. An agent's
15
+ capabilities live as plain files in conventional locations inside the app you
16
+ already run easy to inspect, extend, and operate and its tool effects
17
+ land **exactly once**, even through a crash.
18
+
19
+ ## The filesystem is the authoring interface
20
+
21
+ A typical Silas agent:
19
22
 
20
23
  ```
21
24
  app/agent/
22
- instructions.md # the persona (ERB, snapshotted once per turn)
23
- agent.yml # data-only config: model, limits
24
- tools/ # one file per tool; identity = filename
25
- issue_refund.rb # keyword signature = the schema the model sees
25
+ instructions.md # the persona
26
+ agent.yml # model + limits — data only
27
+ tools/ # one file per tool; filename = identity, keywords = schema
26
28
  skills/ # markdown playbooks, loaded on demand
27
- triage.md # description: frontmatter is the routing hint
28
- ```
29
-
30
- ## Quickstart
31
-
32
- ```sh
33
- bundle add silas
34
- bin/rails generate silas:install
35
- bin/rails db:migrate
36
- bin/rails silas:doctor # key · queue adapter · model · migrations · tools · rescuer · cable · auth
29
+ schedules/ # cron frontmatter -> recurring turns
30
+ channels/ # slack.rb, email.rb — transports bound to the loop
31
+ connections/ # remote MCP servers as <name>.yml
37
32
  ```
38
33
 
39
- ```ruby
40
- class Agent::Tools::IssueRefund < Silas::Tool
41
- description "Refund an order."
42
- param :amount, :integer, desc: "Pence"
43
- approval :always # parks the run; a human approves from your app
44
- transactional! # DB-only side effects -> exactly-once, guaranteed
34
+ Documentation lives at
35
+ **[danielstpaul.github.io/silas](https://danielstpaul.github.io/silas)**
36
+ start with the [tutorial](https://danielstpaul.github.io/silas/tutorial). The
37
+ same docs ship inside the gem (`bundle show silas`), and the installer writes
38
+ a Claude Code skill so coding agents working in your app know the conventions.
45
39
 
46
- def call(order_id:, amount:)
47
- Refund.create!(order_id:, amount:)
48
- { refunded: order_id }
49
- end
50
- end
51
- ```
40
+ ## Quick start
52
41
 
53
- ```ruby
54
- session = Silas.agent.start(input: "Refund order 42, £12.50")
55
- session.pending_approvals.first.approve!(by: "daniel")
56
- session.continue(input: "Now email the customer.")
42
+ ```bash
43
+ rails new my-agent -m https://raw.githubusercontent.com/danielstpaul/silas/main/templates/desk.rb
57
44
  ```
58
45
 
59
- Or talk to it from the terminal the REPL runs *inside your app*, so tools hit
60
- your real dev database, and parked approvals prompt inline (the same
61
- `approve!`/`decline!` as the inbox and Slack):
46
+ This creates a new agent app with Solid Queue wired, a starter refund-desk
47
+ agent installed, and a keyless demo the first `cd my-agent && bin/dev`
48
+ needs no API key. More starting shapes live in
49
+ [templates/](https://github.com/danielstpaul/silas/tree/main/templates) —
50
+ swap `desk.rb` for `analyst.rb` to start from a scheduled reporting agent
51
+ instead.
62
52
 
63
- ```
64
- $ bin/rails silas:chat
65
- you> Refund order 42, £12.50
66
- ✓ lookup_order(order_id: 42)
67
- ⏸ issue_refund(order_id: 42, amount: 1250) — awaiting approval
53
+ To add Silas to an existing app:
68
54
 
69
- approval needed — issue_refund(order_id: 42, amount: 1250)
70
- approve? [y]es / [d]ecline / [s]kip> y
71
- agent> Done — £12.50 refunded on order 42.
55
+ ```bash
56
+ bundle add silas
57
+ bin/rails generate silas:install
58
+ bin/rails db:migrate
72
59
  ```
73
60
 
74
- `SESSION=id` resumes an existing session.
61
+ `bin/rails silas:doctor` verifies the whole setup.
75
62
 
76
- ## The durability contract (what's actually guaranteed)
63
+ ## A minimal example
77
64
 
78
- Verified by `chaos_host/bin/chaos` the harness that kill -9s a live agent
79
- hundreds of times per release (results in `chaos_host/results/`):
65
+ Replace `app/agent/instructions.md` with your agent's persona:
80
66
 
81
- - **A turn survives hard process death** (worker kill -9, whole-tree kill -9,
82
- SIGTERM deploys) and resumes from the last completed step: 100% completion,
83
- byte-identical transcripts, on SQLite and Postgres.
84
- - **`transactional!` tools execute exactly once.** The tool's DB writes and the
85
- ledger row commit or roll back together. Zero duplicates across every chaos run.
86
- - **Other tools are at-least-once within one step** — and when a crash makes an
87
- execution ambiguous, the default `at_most_once!` policy **parks the run for a
88
- human verdict** instead of guessing (`idempotent!` opts into automatic re-runs).
89
- - **Approvals park at zero compute** — the job exits; approving enqueues a fresh
90
- one that replays completed work from rows, never re-calling the model or
91
- re-running tools. Parks expire (default 7 days) rather than ghosting forever.
92
- - **Transient model errors retry from the checkpoint.** A rate limit,
93
- overload, or timeout backs off and retries the job — and the continuation
94
- resumes from the last completed step, never re-running completed work.
95
- Exhausted retries and permanent rejections (bad key, bad request) expire
96
- pending approvals and fail the turn loudly. **A turn can never sit in
97
- `running` forever**: the rescuer also fails turns stranded by a loop job
98
- that died outside the retry list.
99
- - **The rescuer is part of the contract.** Solid Queue marks a dead worker's
100
- jobs failed and nothing retries them; the installer wires
101
- `Silas::DeadJobRescuerJob` as a recurring task (every 30s). Recovery time ≈
102
- `SolidQueue.process_alive_threshold` + that cadence. Do not remove it.
103
- - **Deploys can't corrupt a run**: instructions are snapshotted per turn, and a
104
- deploy that changes tools/skills mid-turn fails the turn loudly
105
- (`NondeterminismError`) instead of resuming into a different agent.
106
-
107
- ## Adapter
108
-
109
- Inference is one pluggable seam (`config.adapter`): `:ruby_llm` — API-key auth
110
- via [RubyLLM](https://rubyllm.com), any provider it supports — is the default
111
- and the production path. Compose resilience via `config.around_model_call`, or
112
- swap in any object responding to `#execute_step` (the eval harness and the
113
- chaos tests do exactly that).
114
-
115
- > The experimental `:agent_sdk` adapter (a `claude -p` subprocess) was removed
116
- > in 0.2: its subscription-auth rationale was structurally unreachable, and it
117
- > carried weaker guarantees than `:ruby_llm` on every axis. Its in-process MCP
118
- > server survives and returns as a first-class *mount your tools as MCP*
119
- > feature.
120
-
121
- ## Sandbox: run untrusted code with hermetic
67
+ ```markdown
68
+ You are the refund desk. Look orders up before promising anything, and never
69
+ quote an amount a tool didn't return.
70
+ ```
122
71
 
123
- The sandbox is a second pluggable seam (`config.sandbox`). Built-in adapters are
124
- `:none` (default code execution off) and `:docker` (hardened container,
125
- honest-but-interim). For real isolation, the companion gem
126
- [**hermetic**](https://github.com/danielstpaul/hermetic) drops straight in:
72
+ Create a tool at `app/agent/tools/issue_refund.rb` the keyword signature
73
+ *is* the schema the model sees:
127
74
 
128
75
  ```ruby
129
- # Gemfile: gem "hermetic" (zero runtime deps)
130
- Silas.configure do |c|
131
- c.sandbox = Hermetic.gvisor(image: "python:3.12-slim") # or .docker /
132
- # .firecracker(kernel:, rootfs:) / .hosted(:e2b, api_key:) # pick your strength
76
+ class Agent::Tools::IssueRefund < Silas::Tool
77
+ description "Refund part or all of an order."
78
+ approval ->(session:, input:) { input[:amount_pence] > 2_500 ? :user_approval : :approved }
79
+ transactional! # DB effect + ledger commit atomically -> exactly-once
80
+
81
+ def call(number:, amount_pence:, reason:)
82
+ order = Order.find_by!(number: number)
83
+ refund = order.refunds.create!(amount_pence:, reason:)
84
+ { refunded_pence: refund.amount_pence, order: order.number }
85
+ end
133
86
  end
134
87
  ```
135
88
 
136
- That's the whole integration. When a sandbox is configured and enabled, the
137
- `run_code` tool is advertised to the model automatically (`at_most_once!` — an
138
- exec is an external effect). Two properties carry through the seam:
139
-
140
- - **The trust axis is visible**: every hermetic backend exposes `trust`
141
- (`:vendor`/`:remote`/`:vm`/`:host`) and `off_host?`, so you can refuse to run
142
- untrusted code on the box that holds your `RAILS_MASTER_KEY` — pair any local
143
- backend with `executor:` to push execution to a dedicated sandbox host.
144
- - **The ledger guard is auto-armed**: configuring a hermetic backend loads its
145
- Silas shim, so a sandbox exec attempted inside a ledger transaction fails loud
146
- (sandbox-backed tools must be `at_most_once!`, never `transactional!`).
147
-
148
- ## Named agents: the staff pattern
149
-
150
- One app can employ several agents, each with its own room:
151
-
152
- ```
153
- app/agents/
154
- reader/ # Silas.agent(:reader).start(input: "...")
155
- instructions.md
156
- agent.yml # model, limits — same keys as the root agent
157
- tools/
158
- skills/
159
- clerk/
160
- ...
161
- ```
162
-
163
- Sessions are stamped with the agent's name; every turn — including crash
164
- resumes — runs under that agent's own tools, skills, instructions, and
165
- definitions digest. The inbox filters by agent; `bin/rails silas:chat
166
- AGENT=clerk` chats with one staff member. The root `app/agent/` remains the
167
- default agent, unchanged. (Subagents stay a root-agent delegation feature;
168
- scope switching is execution-isolated, so concurrent jobs running different
169
- agents never cross wires.)
170
-
171
- ## Memory & handoffs
172
-
173
- Silas memory is **graph-shaped, not a graph database**: facts as
174
- `subject · attribute · content` triples with provenance and supersession
175
- ("author:jane · report_format: prefers CSV" — a new value retires the old).
176
- The `remember` tool is **approval-gated by default** — the memory card parks
177
- in your inbox before anything persists; `recall` digs deeper than the few
178
- recent memories injected into each turn. Private per agent, or `shared: true`
179
- for the whole staff. Your *domain* data does not belong here — it belongs in
180
- your own tables, which your tools already read; memory is for the fuzzy
181
- residue with no natural home.
182
-
183
- Staff compose through **handoffs, not conversations**: `handoff` files a
184
- self-contained brief that starts a linked session for another named agent
185
- (async, or `await: true` for an answer), exactly-once-guarded, cycle-checked.
186
- Two models chatting freely is a cost and audit hazard — deliberately
187
- unblessed.
188
-
189
- ## Triggers
89
+ Restart, then talk to it:
190
90
 
191
- An agent is reached by more than a method call:
192
-
193
- - **`schedules/`** — `app/agent/schedules/*.md` (cron frontmatter, body = the turn
194
- input) or `*.rb` handlers. `bin/rails silas:schedules` compiles them into
195
- Solid Queue recurring tasks. A scheduled run is a normal durable turn. Named
196
- agents own their cron the same way they own tools:
197
- `app/agents/analyst/schedules/monday_kpis.md` ticks start the analyst, not
198
- the root agent.
199
- - **`channels/`** — `app/agent/channels/*.rb` bind email (Action Mailbox) and
200
- Slack to the loop. A new thread starts a session, a reply continues it, and
201
- approvals render as Slack buttons / signed email links that call the same
202
- `approve!`/`decline!`. Outbound delivery is idempotent and off the durable loop.
203
- `bin/rails g silas:channel whatsapp` scaffolds any other transport — a
204
- signature-verifying webhook and the outbound half, wired together. See
205
- [docs/channels.md](https://github.com/danielstpaul/silas/blob/main/docs/channels.md).
206
-
207
- ## Streaming
208
-
209
- Turns stream. The `:ruby_llm` adapter emits text deltas as the model responds:
210
- `bin/rails silas:chat` prints tokens as they arrive, and the inbox trace
211
- renders them live over Turbo (coalesced to ~10Hz). Deltas are decoration over
212
- the durable rows — never persisted, never fed back to the model, and a
213
- replayed step renders from its row with no deltas at all, so streaming adds
214
- zero risk to the durability contract. Custom sinks subscribe to the
215
- `"delta.silas"` notification (`{ session_id:, turn_id:, step_id:, step_index:,
216
- text: }`, where `text` is the accumulated string so far — filter by ids;
217
- notifications are process-global).
218
-
219
- ## Structured answers
220
-
221
- Give the turn's final answer a schema in agent.yml and read it back as a Hash:
222
-
223
- ```yaml
224
- final_answer:
225
- type: object
226
- properties:
227
- verdict: { type: string }
228
- amount_pence: { type: integer }
229
- required: [verdict]
91
+ ```bash
92
+ bin/rails silas:chat
230
93
  ```
231
94
 
232
- `Turn#answer_data` returns the parsed payload (`answer_text` stays for prose
233
- agents); the HTTP API carries it as `answer_data`, evals get
234
- `assert_answer_data(key: :verdict, value: "approve")`. Rendered through
235
- RubyLLM's `with_schema`, so each provider's native structured-output mode is
236
- used. The schema is model-visible state — changing it mid-turn fails the turn
237
- loudly rather than resuming into a different contract.
95
+ Refunds over £25 hold for a person — in the operator inbox the gem mounts at
96
+ `/silas/inbox`, in Slack, or over the JSON API and approving resumes the
97
+ turn exactly where it stopped, with exactly one refund row in your database.
98
+ How that's guaranteed (and verified with a `kill -9` chaos harness on every
99
+ release): [guarantees](https://danielstpaul.github.io/silas/guarantees).
238
100
 
239
- ## The HTTP API
101
+ ## Status
240
102
 
241
- Everything the operator surface can do, over JSON mounted with the engine at
242
- `/silas/api/v1`, **deny-by-default** like the inbox (wire `config.api_auth`;
243
- `config.api_actor` names the identity recorded on approvals):
103
+ Early (0.5.x) and moving fast. Requires Rails >= 8.1 (Active Job
104
+ Continuations) and Solid Queue >= 1.2; any model provider
105
+ [RubyLLM](https://rubyllm.com) supports.
244
106
 
245
- ```sh
246
- curl -X POST .../silas/api/v1/sessions -d "input=Refund order 42, £12.50"
247
- curl .../silas/api/v1/sessions/1?trace=1 # turns + steps + tool calls
248
- curl .../silas/api/v1/sessions/1/approvals # what's parked
249
- curl -X POST .../silas/api/v1/approvals/7/approve # the same approve! as the inbox
250
- curl -X POST .../silas/api/v1/sessions/1/turns -d "input=Now email them" # 409 if busy
251
- curl -X POST .../silas/api/v1/turns/9/cancel
252
- curl -N .../silas/api/v1/sessions/1/stream # server-sent events
253
- ```
254
-
255
- The stream is SSE at **row granularity** — turn / completed-step / invocation
256
- changes, at-least-once with `Last-Event-ID` resume (ids are epoch-ms
257
- watermarks; `?poll=1` returns the backlog and closes, curl-friendly; streams
258
- close themselves after `api_stream_max_duration` and clients reconnect).
259
- Per-token streaming is deliberately the browser/Turbo feature — deltas live in
260
- the worker process, and the gem requires no cross-process bus.
261
-
262
- ## The inbox
107
+ ## Community
263
108
 
264
- Mount the engine (the generator does this) and a live inbox appears at
265
- `/silas/inbox`: a session list, **web chat** (start a session or reply from
266
- the browser — same durable loop, no separate surface), a live step-trace that
267
- streams tokens over Turbo Streams as the agent runs, approval cards whose
268
- Approve/Decline buttons call the exact same `approve!`/`decline!` as Slack and
269
- email, a full **audit trail** (every tool call's arguments and its result or
270
- recorded error; who approved; who declined and why), **cancel** on active
271
- turns (honored at the next step boundary), and per-session token/cost
272
- accounting. It's **deny-by-default** — invisible until you wire auth:
109
+ Questions, ideas, and bug reports
110
+ [issues](https://github.com/danielstpaul/silas/issues).
273
111
 
274
- ```ruby
275
- Silas.configure do |c|
276
- # Devise-compatible: the lambda DENIES by rendering; passes by not rendering.
277
- c.inbox_auth = ->(controller) { controller.head :not_found unless controller.current_user&.admin? }
278
- # c.inbox_public_read = true # public read-only demo; approve/decline stay gated
279
- # c.model_prices["your-model"] = { in: 300, out: 1500 } # microcents / 1k tokens
280
- end
281
- ```
112
+ ## Contributing
282
113
 
283
- Turbo streaming activates automatically when the host has `turbo-rails` (every
284
- default Rails app does); without it the trace falls back to a polling refresh.
285
- The gem itself takes no turbo dependency.
114
+ See [CONTRIBUTING.md](https://github.com/danielstpaul/silas/blob/main/CONTRIBUTING.md)
115
+ including the short list of things Silas deliberately doesn't do, and the
116
+ chaos gate that protects the durability contract.
286
117
 
287
- ## Requirements
118
+ ## Security
288
119
 
289
- Rails >= 8.1 (Active Job Continuations) and Solid Queue >= 1.2 for the
290
- durability contract. macOS dev note: Solid Queue forks + pg need
291
- `PGGSSENCMODE=disable OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES`.
120
+ Please report vulnerabilities privately via
121
+ [GitHub's vulnerability reporting](https://github.com/danielstpaul/silas/security/advisories/new)
122
+ see [SECURITY.md](https://github.com/danielstpaul/silas/blob/main/SECURITY.md).
292
123
 
293
124
  ## License
294
125
 
295
- MIT.
126
+ [MIT](https://github.com/danielstpaul/silas/blob/main/LICENSE).
@@ -1,17 +1,32 @@
1
1
  module Silas
2
2
  module Inbox
3
3
  module TraceHelper
4
+ # The seven run states, in aspect (direction "Signals"): running is the
5
+ # only aspect that pulses, in_doubt gets its own violet (it is neither
6
+ # waiting-by-design nor failed), and canceled is a lamp going OUT —
7
+ # dashed quiet, never red. Failed keeps the only red.
4
8
  STATUS_CLASS = {
5
9
  "queued" => "pill-grey", "running" => "pill-blue pill-pulse",
6
- "waiting" => "pill-amber", "in_doubt" => "pill-amber",
7
- "completed" => "pill-green", "failed" => "pill-red", "canceled" => "pill-red",
8
- # tool-invocation statuses
10
+ "waiting" => "pill-amber", "in_doubt" => "pill-violet",
11
+ "completed" => "pill-green", "failed" => "pill-red", "canceled" => "pill-quiet",
12
+ # tool-invocation statuses map onto the same seven
9
13
  "pending" => "pill-grey", "started" => "pill-blue", "declined" => "pill-red",
10
- "approved" => "pill-green", "required" => "pill-amber", "expired" => "pill-red"
14
+ "approved" => "pill-green", "answered" => "pill-green",
15
+ "required" => "pill-amber", "expired" => "pill-quiet"
11
16
  }.freeze
12
17
 
18
+ # UI-only relabels — the database strings and the JSON API are untouched
19
+ # (an operator who reads "held" here and greps the API will find
20
+ # `waiting`; docs name both). Safety-system vocabulary: a turn is held
21
+ # at the signal until a person clears it.
22
+ UI_LABEL = { "waiting" => "held", "completed" => "clear" }.freeze
23
+
24
+ def status_label(status)
25
+ UI_LABEL[status.to_s] || status.to_s.tr("_", " ")
26
+ end
27
+
13
28
  def status_pill(status)
14
- tag.span(status.to_s.tr("_", " "), class: "pill #{STATUS_CLASS[status.to_s] || 'pill-grey'}")
29
+ tag.span(status_label(status), class: "pill #{STATUS_CLASS[status.to_s] || 'pill-grey'}")
15
30
  end
16
31
 
17
32
  def step_text(step)
@@ -50,7 +65,15 @@ module Silas
50
65
  # bare broadcast renderer doesn't have — so broadcast-rendered partials
51
66
  # build paths from the engine's own route set + the discovered mount.
52
67
  def silas_engine_path(helper, *args)
53
- Silas::Engine.routes.url_helpers.public_send(helper, *args, script_name: Silas::Inbox.mount_path)
68
+ helpers = Silas::Engine.routes.url_helpers
69
+ # Rails 8.1's lazy route set: in a worker's FIRST broadcast render the
70
+ # engine's helper module can predate its route draw, and the lazy
71
+ # method_missing only retries when the app routes were *just* loaded —
72
+ # otherwise it raises NoMethodError and the Turbo job dies silently
73
+ # (observed: the first turn broadcast of a fresh worker). Force the
74
+ # draw once on miss; every later call takes the fast path.
75
+ Rails.application.reload_routes! unless helpers.respond_to?(helper)
76
+ helpers.public_send(helper, *args, script_name: Silas::Inbox.mount_path)
54
77
  end
55
78
  end
56
79
  end
@@ -57,6 +57,18 @@ module Silas
57
57
 
58
58
  silas_inbox_dispatch(:replace, turn.session_id, target: ActionView::RecordIdentifier.dom_id(self),
59
59
  partial: "silas/inbox/invocations/invocation", locals: { invocation: self })
60
+
61
+ # The hoisted card (session top): a fresh park APPENDS it there —
62
+ # a replace can't, since no target exists until the park — and any
63
+ # later transition replaces it in place (the partial renders an
64
+ # empty shell once settled, so the card vanishes).
65
+ if saved_change_to_approval_state?
66
+ action = approval_state == "required" ? :append : :replace
67
+ target = action == :append ? "silas-session-#{turn.session_id}-approvals"
68
+ : ActionView::RecordIdentifier.dom_id(self, :approval)
69
+ silas_inbox_dispatch(action, turn.session_id, target: target,
70
+ partial: "silas/inbox/invocations/approval_card", locals: { invocation: self })
71
+ end
60
72
  end
61
73
  end
62
74
  end