Class: Agentilda::StateMachine
- Inherits:
-
Object
- Object
- Agentilda::StateMachine
- Includes:
- AASM
- Defined in:
- lib/agentilda/state_machine.rb
Overview
The state machine for one plan folder.
The whole topology is the aasm block below, and it is nowhere else. To
change what may follow what, edit an event's from: list. To add a state,
add it to STATUSES and give it an event. There is no second table to keep
in step: StateMachine.inbound, StateMachine.outbound, StateMachine.edge? and terminal? are all read back
off these declarations.
Two rules hold the rest together:
- The guard for entering a state is that state's own invariant. There
is one guard method, #justified?, and it reads the destination off the
transition in flight — so a destination is named once, in
to:. - The state lives in the folder name. There is no column and no database. A successful transition renames the directory, which is why firing an event is a real side effect and asking a question is not.
Defined Under Namespace
Classes: Refused
Constant Summary collapse
- SPINE =
The forward path a bare #promote! walks. Everything off this spine — blocking, deferring, discarding — has to be named explicitly, which is the entire reason for having a machine rather than a rename.
🔴 rejoins at 🎨 rather than continuing: fixing review comments puts the work back through both halves of building, it does not skip the reviewer and it does not assume the half nobody complained about still holds.
{ retroactive: :planned, new: :researched, researched: :planned, planned: :building, building: :building_ui, building_ui: :ready_for_review, ready_for_review: :in_review, in_review: :approved, approved: :deployed, rejected: :building_ui, rolled_back: :ready_for_review, shit: :planned }.freeze
- PREFERENCE =
Preference order when several states fit a folder's contents at once. Read top to bottom: the loudest fact wins. A discard outranks everything, a block outranks progress, and
retroactiveis last because it is the absence of documents rather than the presence of any. %i[ discarded rolled_back shit deferred blocked product_blocked deployed approved rejected in_review ready_for_review building planned researched new retroactive ].freeze
- FAMILIES =
States whose members a folder's contents cannot tell apart. Within a family the current name always wins, because re-deriving it would be a guess dressed up as a correction.
⭕️ and 🅱️ both mean "a human must decide before this moves"; which human is recorded nowhere but the emoji.
🟡 🟢 👀 🔴 all look identical on disk — a
plan.mdand some open pull requests. Whether someone is still building, CI is green and a reviewer is wanted, a reviewer is reading it, or a reviewer asked for changes is not written down anywhere a program could read. Soresyncnever moves between them; they advance by events alone.Order matters: the first member is the weakest claim in the family, and it is where a folder arriving from outside lands. Contents that fit the family justify only its floor, never its ceiling.
[ %i[blocked product_blocked], %i[building ready_for_review in_review rejected] ].freeze
- SETTLED =
States the agent loop leaves alone: work that is finished (✅ 😎), work that was dropped (❌), and work waiting on a human (⭕️ 🅱️ ☢️). Everything else is fair game for a specialist.
✅ Approved is here deliberately.
hansolo-revieweradvancing a plan to it is the end of the loop, not a step in it: nothing merges, and no agent handlesapproved. Merging is the one act in this lifecycle that changes a branch everyone else builds on, and an autonomous loop that does it unattended has no way to be wrong quietly.Moving
approvedout of this list is therefore a decision about blast radius rather than about topology. If it ever moves, something has to ownapproved -> deployed, and today nothing does. %i[approved deployed discarded blocked product_blocked deferred].freeze
Instance Attribute Summary collapse
-
#key ⇒ Symbol
readonly
The state right now.
-
#subject ⇒ Object
readonly
The plan folder this machine speaks for.
Class Method Summary collapse
-
.edge?(from, to) ⇒ Boolean
Whether the topology permits this edge at all.
-
.event_for ⇒ Hash{Symbol => Symbol}
Destination => the event that reaches it.
-
.family_of(key) ⇒ Array<Symbol>
The family
keybelongs to, empty when it has none. -
.inbound ⇒ Hash{Symbol => Array<Symbol>}
Destination => the states that may legitimately reach it.
- .outbound(key) ⇒ Array<Symbol>
-
.outbound_map ⇒ Hash{Symbol => Array<Symbol>}
Source => the states it may reach.
Instance Method Summary collapse
-
#aasm_read_state(_name = :default) ⇒ Symbol
AASM keeps state in an ORM column; we keep it in a directory name.
- #aasm_write_state(new_state, name = :default) ⇒ Boolean
- #aasm_write_state_without_persistence(new_state, _name = :default) ⇒ Boolean
-
#allowed ⇒ Array<Symbol>
States reachable right now, guards applied.
-
#best_fit ⇒ Agentilda::Status?
The state a folder's contents justify — the answer to "well, what should it be, then?".
-
#initialize(subject) ⇒ StateMachine
constructor
A new instance of StateMachine.
-
#may?(to) ⇒ Boolean
Whether that move is permitted right now.
-
#promote!(to = spine_next) ⇒ Agentilda::Status
Move the folder.
-
#spine_next ⇒ Symbol?
The next state along the spine, whether or not its guard passes.
- #status ⇒ Agentilda::Status
Constructor Details
#initialize(subject) ⇒ StateMachine
Returns a new instance of StateMachine.
240 241 242 243 |
# File 'lib/agentilda/state_machine.rb', line 240 def initialize(subject) @subject = subject @key = subject.status.key end |
Instance Attribute Details
#key ⇒ Symbol (readonly)
Returns the state right now.
249 250 251 |
# File 'lib/agentilda/state_machine.rb', line 249 def key @key end |
#subject ⇒ Object (readonly)
Returns the plan folder this machine speaks for.
246 247 248 |
# File 'lib/agentilda/state_machine.rb', line 246 def subject @subject end |
Class Method Details
.edge?(from, to) ⇒ Boolean
Returns whether the topology permits this edge at all.
232 |
# File 'lib/agentilda/state_machine.rb', line 232 def edge?(from, to) = outbound(from).include?(to) |
.event_for ⇒ Hash{Symbol => Symbol}
Destination => the event that reaches it. Every event above has exactly
one to:, which is what lets --to <state> name a destination rather
than making callers learn the verbs.
198 199 200 201 202 |
# File 'lib/agentilda/state_machine.rb', line 198 def event_for @event_for ||= aasm.events.each_with_object({}) { |event, map| event.transitions.each { |t| map[t.to] ||= event.name } }.freeze end |
.family_of(key) ⇒ Array<Symbol>
Returns the family key belongs to, empty when it has none.
236 |
# File 'lib/agentilda/state_machine.rb', line 236 def family_of(key) = FAMILIES.find { |family| family.include?(key) } || [] |
.inbound ⇒ Hash{Symbol => Array<Symbol>}
Destination => the states that may legitimately reach it.
retroactive is absent: it is a birth state, produced by
create --after for work that shipped undocumented.
210 211 212 213 214 |
# File 'lib/agentilda/state_machine.rb', line 210 def inbound @inbound ||= aasm.events.each_with_object({}) { |event, map| event.transitions.each { |t| (map[t.to] ||= []).concat(Array(t.from)) } }.transform_values { |froms| froms.uniq.freeze }.freeze end |
.outbound(key) ⇒ Array<Symbol>
227 |
# File 'lib/agentilda/state_machine.rb', line 227 def outbound(key) = outbound_map.fetch(key, []) |
.outbound_map ⇒ Hash{Symbol => Array<Symbol>}
Source => the states it may reach.
219 220 221 222 223 |
# File 'lib/agentilda/state_machine.rb', line 219 def outbound_map @outbound_map ||= inbound.each_with_object({}) { |(to, froms), map| froms.each { |from| (map[from] ||= []) << to } }.transform_values(&:freeze).freeze end |
Instance Method Details
#aasm_read_state(_name = :default) ⇒ Symbol
AASM keeps state in an ORM column; we keep it in a directory name. These three methods are the whole of that adaptation.
258 |
# File 'lib/agentilda/state_machine.rb', line 258 def aasm_read_state(_name = :default) = @key |
#aasm_write_state(new_state, name = :default) ⇒ Boolean
261 |
# File 'lib/agentilda/state_machine.rb', line 261 def aasm_write_state(new_state, name = :default) = aasm_write_state_without_persistence(new_state, name) |
#aasm_write_state_without_persistence(new_state, _name = :default) ⇒ Boolean
264 265 266 267 |
# File 'lib/agentilda/state_machine.rb', line 264 def aasm_write_state_without_persistence(new_state, _name = :default) @key = new_state true end |
#allowed ⇒ Array<Symbol>
States reachable right now, guards applied.
272 |
# File 'lib/agentilda/state_machine.rb', line 272 def allowed = aasm.states(permitted: true).map(&:name) |
#best_fit ⇒ Agentilda::Status?
The state a folder's contents justify — the answer to "well, what should
it be, then?". This is what resync dirs renames toward.
Invariants are minimum requirements, not exact matches: a ⚪️ folder
that has since grown a plan.md still satisfies ⚪️, and is nonetheless
⭐️ now. So the furthest-justified state wins, and the caller compares it
against the current one to decide whether anything should move.
The exception is a family, whose members contents cannot tell apart. Two rules cover it:
- Already inside one — the current name wins. Re-deriving it would be a guess dressed up as a correction, and every ⭕️ would become 🅱️.
- Arriving from outside — the family's first member wins, not the furthest. A ✅ folder found with an open pull request is demonstrably back in the PR phase; nothing shows whether a reviewer has seen it, so it lands on 🟡 rather than claiming 🔴.
319 320 321 322 323 324 325 326 327 328 |
# File 'lib/agentilda/state_machine.rb', line 319 def best_fit fitting = STATUSES.select { |s| s.satisfied_by?(subject) } return nil if fitting.empty? best = PREFERENCE.filter_map { |k| fitting.find { |s| s.key == k } }.first || fitting.first return status if self.class.family_of(key).include?(best.key) family = self.class.family_of(best.key) family.empty? ? best : STATUS_BY_KEY.fetch(family.first) end |
#may?(to) ⇒ Boolean
Returns whether that move is permitted right now.
276 |
# File 'lib/agentilda/state_machine.rb', line 276 def may?(to) = allowed.include?(to) |
#promote!(to = spine_next) ⇒ Agentilda::Status
291 292 293 294 295 296 297 298 |
# File 'lib/agentilda/state_machine.rb', line 291 def promote!(to = spine_next) raise Refused, "#{describe(key)} is terminal — nothing follows it" if to.nil? raise Refused, "nothing can reach #{describe(to)}" unless self.class.event_for.key?(to) raise Refused, refusal(to) unless may?(to) aasm.fire!(self.class.event_for.fetch(to)) status end |
#spine_next ⇒ Symbol?
The next state along the spine, whether or not its guard passes.
281 |
# File 'lib/agentilda/state_machine.rb', line 281 def spine_next = SPINE[key] |
#status ⇒ Agentilda::Status
252 |
# File 'lib/agentilda/state_machine.rb', line 252 def status = STATUS_BY_KEY.fetch(key) |