agentilda 2.0.1 → 2.0.4
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.
- checksums.yaml +4 -4
- data/lib/agentilda/agents/agent.rb +79 -0
- data/lib/agentilda/agents/registry.rb +121 -0
- data/lib/agentilda/agents/roster.rb +92 -0
- data/lib/agentilda/agents.rb +11 -113
- data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
- data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
- data/lib/agentilda/cli/base.rb +3 -3
- data/lib/agentilda/cli/create/create.rb +17 -18
- data/lib/agentilda/cli/docs/docs.rb +1 -1
- data/lib/agentilda/cli/index/index.rb +1 -1
- data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
- data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
- data/lib/agentilda/cli/mail/mail.rb +4 -4
- data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
- data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
- data/lib/agentilda/cli/run/run.rb +40 -40
- data/lib/agentilda/cli/states/states.rb +1 -1
- data/lib/agentilda/cli/unblock/unblock.rb +17 -17
- data/lib/agentilda/cli/worktree/worktree.rb +4 -4
- data/lib/agentilda/cli.rb +1 -1
- data/lib/agentilda/engine/board.rb +93 -0
- data/lib/agentilda/engine/dispatcher.rb +646 -0
- data/lib/agentilda/engine/progress_log.rb +125 -0
- data/lib/agentilda/engine/runner.rb +141 -0
- data/lib/agentilda/engine/state_file.rb +176 -0
- data/lib/agentilda/engine/tally.rb +177 -0
- data/lib/agentilda/engine.rb +19 -0
- data/lib/agentilda/execution/child.rb +94 -0
- data/lib/agentilda/execution/clock.rb +116 -0
- data/lib/agentilda/execution/control.rb +180 -0
- data/lib/agentilda/execution/executor.rb +699 -0
- data/lib/agentilda/execution/transcript.rb +468 -0
- data/lib/agentilda/execution.rb +18 -0
- data/lib/agentilda/lifecycle/adoption.rb +197 -0
- data/lib/agentilda/lifecycle/brief.rb +285 -0
- data/lib/agentilda/lifecycle/creator.rb +125 -0
- data/lib/agentilda/lifecycle/resync.rb +428 -0
- data/lib/agentilda/lifecycle/unblocker.rb +223 -0
- data/lib/agentilda/lifecycle.rb +28 -0
- data/lib/agentilda/linear/api.rb +1 -1
- data/lib/agentilda/linear/attribution.rb +7 -7
- data/lib/agentilda/linear/import.rb +18 -18
- data/lib/agentilda/linear/mapping.rb +6 -6
- data/lib/agentilda/linear/push.rb +5 -5
- data/lib/agentilda/linear/survey.rb +7 -7
- data/lib/agentilda/linear/unit.rb +5 -5
- data/lib/agentilda/plans/dev_work.rb +56 -0
- data/lib/agentilda/plans/feature.rb +131 -0
- data/lib/agentilda/plans/index.rb +208 -0
- data/lib/agentilda/plans/ledger.rb +235 -0
- data/lib/agentilda/plans/mailbox.rb +143 -0
- data/lib/agentilda/plans/ordinal.rb +92 -0
- data/lib/agentilda/plans/pull_request.rb +29 -0
- data/lib/agentilda/plans/pull_requests.rb +229 -0
- data/lib/agentilda/plans/state_machine.rb +383 -0
- data/lib/agentilda/plans/status.rb +365 -0
- data/lib/agentilda/plans/subject.rb +131 -0
- data/lib/agentilda/plans/tree.rb +79 -0
- data/lib/agentilda/plans.rb +39 -0
- data/lib/agentilda/presentation/console.rb +159 -0
- data/lib/agentilda/presentation/dashboard.rb +221 -0
- data/lib/agentilda/presentation/diagram.rb +149 -0
- data/lib/agentilda/presentation/documentation.rb +439 -0
- data/lib/agentilda/presentation/keyboard.rb +158 -0
- data/lib/agentilda/presentation/reporter.rb +181 -0
- data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
- data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
- data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
- data/lib/agentilda/presentation/viewer.rb +62 -0
- data/lib/agentilda/presentation.rb +23 -0
- data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
- data/lib/agentilda/vcs/github.rb +170 -0
- data/lib/agentilda/vcs/publisher.rb +177 -0
- data/lib/agentilda/vcs/worktree.rb +209 -0
- data/lib/agentilda/vcs.rb +19 -0
- data/lib/agentilda/version.rb +1 -1
- data/lib/agentilda.rb +6 -5
- metadata +56 -49
- data/lib/agentilda/adoption.rb +0 -197
- data/lib/agentilda/agent.rb +0 -77
- data/lib/agentilda/board.rb +0 -91
- data/lib/agentilda/brief.rb +0 -283
- data/lib/agentilda/child.rb +0 -92
- data/lib/agentilda/clock.rb +0 -114
- data/lib/agentilda/console.rb +0 -157
- data/lib/agentilda/control.rb +0 -178
- data/lib/agentilda/creator.rb +0 -125
- data/lib/agentilda/dashboard.rb +0 -219
- data/lib/agentilda/dev_work.rb +0 -56
- data/lib/agentilda/diagram.rb +0 -149
- data/lib/agentilda/dispatcher.rb +0 -646
- data/lib/agentilda/documentation.rb +0 -437
- data/lib/agentilda/executor.rb +0 -697
- data/lib/agentilda/feature.rb +0 -132
- data/lib/agentilda/github.rb +0 -168
- data/lib/agentilda/index.rb +0 -206
- data/lib/agentilda/keyboard.rb +0 -156
- data/lib/agentilda/ledger.rb +0 -233
- data/lib/agentilda/mailbox.rb +0 -141
- data/lib/agentilda/ordinal.rb +0 -90
- data/lib/agentilda/progress_log.rb +0 -126
- data/lib/agentilda/publisher.rb +0 -175
- data/lib/agentilda/pull_request.rb +0 -27
- data/lib/agentilda/pull_requests.rb +0 -230
- data/lib/agentilda/reporter.rb +0 -181
- data/lib/agentilda/resync.rb +0 -426
- data/lib/agentilda/roster.rb +0 -93
- data/lib/agentilda/runner.rb +0 -139
- data/lib/agentilda/screen/ratatui/bar.rb +0 -57
- data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
- data/lib/agentilda/screen/ratatui.rb +0 -318
- data/lib/agentilda/state_file.rb +0 -174
- data/lib/agentilda/state_machine.rb +0 -383
- data/lib/agentilda/status.rb +0 -363
- data/lib/agentilda/subject.rb +0 -133
- data/lib/agentilda/tally.rb +0 -175
- data/lib/agentilda/transcript.rb +0 -466
- data/lib/agentilda/tree.rb +0 -81
- data/lib/agentilda/unblocker.rb +0 -221
- data/lib/agentilda/viewer.rb +0 -60
- data/lib/agentilda/worktree.rb +0 -207
- /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
- /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
- /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module Presentation
|
|
5
|
+
# What the keys do to the screen, and to the running agents.
|
|
6
|
+
#
|
|
7
|
+
# Selection and the dialog live here, never on the screen: the screen
|
|
8
|
+
# draws whatever board it is handed, and this decides what the board
|
|
9
|
+
# says. Pending changes are visible only in the dialog and take effect
|
|
10
|
+
# only on ENTER, so a stray `k` kills nobody.
|
|
11
|
+
class Console
|
|
12
|
+
# Seconds each `x` adds.
|
|
13
|
+
EXTENSION = 600
|
|
14
|
+
|
|
15
|
+
# @param screen [Agentilda::Presentation::Screen::Ratatui]
|
|
16
|
+
def initialize(screen:)
|
|
17
|
+
@screen = screen
|
|
18
|
+
@dispatcher = nil
|
|
19
|
+
@board = nil
|
|
20
|
+
@selected = nil
|
|
21
|
+
@dialog = false
|
|
22
|
+
@help = false
|
|
23
|
+
@about = false
|
|
24
|
+
@pending = { kill: false, extend: 0 }
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [String, nil] the selected row's key
|
|
29
|
+
attr_reader :selected
|
|
30
|
+
|
|
31
|
+
# @return [Hash] `{kill: Boolean, extend: Integer}`
|
|
32
|
+
attr_reader :pending
|
|
33
|
+
|
|
34
|
+
# @return [Agentilda::Presentation::Screen::Ratatui]
|
|
35
|
+
attr_reader :screen
|
|
36
|
+
|
|
37
|
+
# @return [Boolean]
|
|
38
|
+
def dialog? = @dialog
|
|
39
|
+
|
|
40
|
+
# @return [Boolean]
|
|
41
|
+
def help? = @help
|
|
42
|
+
|
|
43
|
+
# @return [Boolean]
|
|
44
|
+
def about? = @about
|
|
45
|
+
|
|
46
|
+
# The dispatcher exists only once the run starts, after the keyboard is
|
|
47
|
+
# already listening, so it arrives late.
|
|
48
|
+
#
|
|
49
|
+
# @param dispatcher [Agentilda::Engine::Dispatcher]
|
|
50
|
+
# @return [void]
|
|
51
|
+
def attach(dispatcher) = @dispatcher = dispatcher
|
|
52
|
+
|
|
53
|
+
# Called by the dispatcher each tick.
|
|
54
|
+
#
|
|
55
|
+
# @param board [Agentilda::Engine::Board]
|
|
56
|
+
# @return [void]
|
|
57
|
+
def paint(board)
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
@board = board
|
|
60
|
+
@selected = nil if @selected && board.rows.none? { |r| r.key == @selected && r.running? }
|
|
61
|
+
@screen.draw(board.with(selected: @selected,
|
|
62
|
+
dialog: (dialog_text if @dialog),
|
|
63
|
+
help: (help_text if @help),
|
|
64
|
+
about: (about_text if @about)))
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [void]
|
|
69
|
+
def select_next = move(1)
|
|
70
|
+
|
|
71
|
+
# @return [void]
|
|
72
|
+
def select_prev = move(-1)
|
|
73
|
+
|
|
74
|
+
# @return [void]
|
|
75
|
+
def toggle_kill
|
|
76
|
+
select_next unless @selected
|
|
77
|
+
return unless @selected
|
|
78
|
+
|
|
79
|
+
@pending[:kill] = !@pending[:kill]
|
|
80
|
+
@dialog = true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [void]
|
|
84
|
+
def extend
|
|
85
|
+
select_next unless @selected
|
|
86
|
+
return unless @selected
|
|
87
|
+
|
|
88
|
+
@pending[:extend] += EXTENSION
|
|
89
|
+
@dialog = true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# ENTER.
|
|
93
|
+
#
|
|
94
|
+
# @return [void]
|
|
95
|
+
def apply
|
|
96
|
+
return unless @dialog && @selected && @dispatcher
|
|
97
|
+
|
|
98
|
+
@dispatcher.kill(@selected) if @pending[:kill]
|
|
99
|
+
@dispatcher.extend(@selected, @pending[:extend]) if @pending[:extend].positive?
|
|
100
|
+
UI.log("applied to #{@selected}: #{dialog_text.tr("\n", ", ")}")
|
|
101
|
+
close_dialog
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# ESC: the dialog first, then the help, then the about screen, then the
|
|
105
|
+
# selection.
|
|
106
|
+
#
|
|
107
|
+
# @return [void]
|
|
108
|
+
def escape
|
|
109
|
+
if @dialog
|
|
110
|
+
close_dialog
|
|
111
|
+
elsif @help
|
|
112
|
+
@help = false
|
|
113
|
+
elsif @about
|
|
114
|
+
@about = false
|
|
115
|
+
else
|
|
116
|
+
@selected = nil
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# @return [void]
|
|
121
|
+
def toggle_help = @help = !@help
|
|
122
|
+
|
|
123
|
+
# @return [void]
|
|
124
|
+
def toggle_about = @about = !@about
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# @return [void]
|
|
129
|
+
def close_dialog
|
|
130
|
+
@dialog = false
|
|
131
|
+
@pending = { kill: false, extend: 0 }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @param step [Integer]
|
|
135
|
+
# @return [void]
|
|
136
|
+
def move(step)
|
|
137
|
+
keys = (@board&.rows || []).select(&:running?).map(&:key)
|
|
138
|
+
return @selected = nil if keys.empty?
|
|
139
|
+
|
|
140
|
+
index = keys.index(@selected)
|
|
141
|
+
@selected = index.nil? ? keys.first : keys[(index + step) % keys.size]
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @return [String]
|
|
145
|
+
def dialog_text
|
|
146
|
+
row = @board&.rows&.find { |r| r.key == @selected }
|
|
147
|
+
who = row ? "#{row.ordinal} #{row.agent}" : @selected.to_s
|
|
148
|
+
extension = @pending[:extend].positive? ? "+#{@pending[:extend] / 60}m" : "none"
|
|
149
|
+
"#{who}\n\nkill: #{@pending[:kill] ? "yes" : "no"}\nextend: #{extension}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# @return [String]
|
|
153
|
+
def help_text = Keyboard.new(input: $stdin).help
|
|
154
|
+
|
|
155
|
+
# @return [String]
|
|
156
|
+
def about_text = Keyboard.new(input: $stdin).about
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module Presentation
|
|
5
|
+
# The ratatui dashboard for agent work that is not a `run`: `unblock`
|
|
6
|
+
# handing plans to lando-broker, `create` drafting a spec.md. `run` has a
|
|
7
|
+
# {Dispatcher} to build its {Board}; this builds one from the items
|
|
8
|
+
# {UI.concurrently} hands it, so every agent a person waits on is drawn
|
|
9
|
+
# the same way, with the same keys.
|
|
10
|
+
#
|
|
11
|
+
# It stands in for the dispatcher behind {Console}, which is why it
|
|
12
|
+
# answers {#kill} and {#extend}: a row whose work passed its
|
|
13
|
+
# {Tracker#handle} on to {Executor#call} can be killed or given more time.
|
|
14
|
+
class Dashboard
|
|
15
|
+
# Seconds between frames handed to the screen.
|
|
16
|
+
REFRESH = 0.25
|
|
17
|
+
|
|
18
|
+
# Seconds a finished row stays on screen, so a fast agent is still seen.
|
|
19
|
+
LINGER = 3
|
|
20
|
+
|
|
21
|
+
# One item's row, and the {UI::Line} its work streams progress through.
|
|
22
|
+
# Nothing is drawn by the line itself; the dashboard reads it each frame.
|
|
23
|
+
class Tracker < UI::Line
|
|
24
|
+
# @return [String]
|
|
25
|
+
attr_reader :key
|
|
26
|
+
|
|
27
|
+
# @return [Agentilda::Execution::Executor::Handle] handed to {Executor#call} by a
|
|
28
|
+
# caller that wants the kill and extend keys to reach its agent.
|
|
29
|
+
# Not `@handle`: {UI::Line} keeps its dry-cli-ui line under that name.
|
|
30
|
+
def handle = @executor_handle
|
|
31
|
+
|
|
32
|
+
# @return [Symbol] :running, :done or :failed
|
|
33
|
+
attr_reader :state
|
|
34
|
+
|
|
35
|
+
# @return [Float, nil] monotonic reading of when it stopped running
|
|
36
|
+
attr_reader :finished_at
|
|
37
|
+
|
|
38
|
+
# @param key [String]
|
|
39
|
+
# @param fields [Hash] plan, status, agent and round, for the log and the row
|
|
40
|
+
# @param file [String] what the row's feature column says
|
|
41
|
+
# @param message [String] shown until the agent says something
|
|
42
|
+
# @param timeout [Integer, nil]
|
|
43
|
+
def initialize(key:, fields:, file:, message:, timeout: nil)
|
|
44
|
+
super(fields:, timeout:)
|
|
45
|
+
@key = key
|
|
46
|
+
@file = file
|
|
47
|
+
@message = message
|
|
48
|
+
@executor_handle = Execution::Executor::Handle.new
|
|
49
|
+
@state = :running
|
|
50
|
+
@update = nil
|
|
51
|
+
@history = []
|
|
52
|
+
@finished_at = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param update [Agentilda::Execution::Transcript::Progress]
|
|
56
|
+
# @return [void]
|
|
57
|
+
def call(update)
|
|
58
|
+
super
|
|
59
|
+
@update = update
|
|
60
|
+
return unless update.activity
|
|
61
|
+
|
|
62
|
+
@message = update.activity
|
|
63
|
+
@history = Engine::Board::Row.remember(@history, @message)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [void]
|
|
67
|
+
def done
|
|
68
|
+
super
|
|
69
|
+
finish(:done)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @param reason [String]
|
|
73
|
+
# @return [void]
|
|
74
|
+
def failed(reason)
|
|
75
|
+
super
|
|
76
|
+
@message = reason
|
|
77
|
+
@history = Engine::Board::Row.remember(@history, reason)
|
|
78
|
+
finish(:failed)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# @return [Agentilda::Engine::Board::Row]
|
|
82
|
+
def row
|
|
83
|
+
Engine::Board::Row.new(key:,
|
|
84
|
+
at: Time.now,
|
|
85
|
+
ordinal: @fields[:plan].to_s,
|
|
86
|
+
file: @file,
|
|
87
|
+
agent: @fields[:agent].to_s,
|
|
88
|
+
role: @fields[:agent].to_s,
|
|
89
|
+
round: @fields[:round] || 1,
|
|
90
|
+
rounds: @fields[:round] || 1,
|
|
91
|
+
model: "default",
|
|
92
|
+
remaining: handle.remaining || remaining,
|
|
93
|
+
phase: handle.phase,
|
|
94
|
+
up: @update&.up.to_i,
|
|
95
|
+
down: @update&.down.to_i,
|
|
96
|
+
message: @message,
|
|
97
|
+
state:,
|
|
98
|
+
elapsed: seconds.round,
|
|
99
|
+
subagents: @update&.subagents.to_i,
|
|
100
|
+
history: @history)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# @param state [Symbol]
|
|
106
|
+
# @return [void]
|
|
107
|
+
def finish(state)
|
|
108
|
+
@state = state
|
|
109
|
+
@finished_at = UI.monotonic
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Opens the screen, runs the block, and always closes it again.
|
|
114
|
+
#
|
|
115
|
+
# @param root [String] shown in the bottom bar
|
|
116
|
+
# @param screen [Agentilda::Presentation::Screen::Ratatui]
|
|
117
|
+
# @yieldparam dashboard [Agentilda::Presentation::Dashboard]
|
|
118
|
+
# @return [Object] the block's value
|
|
119
|
+
def self.open(root: Dir.pwd, screen: Screen::Ratatui.new)
|
|
120
|
+
dashboard = new(root:, screen:)
|
|
121
|
+
dashboard.start
|
|
122
|
+
begin
|
|
123
|
+
yield dashboard
|
|
124
|
+
ensure
|
|
125
|
+
dashboard.stop
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# @param root [String]
|
|
130
|
+
# @param screen [Agentilda::Presentation::Screen::Ratatui]
|
|
131
|
+
def initialize(root:, screen:)
|
|
132
|
+
@root = root
|
|
133
|
+
@screen = screen
|
|
134
|
+
@console = Console.new(screen:)
|
|
135
|
+
@console.attach(self)
|
|
136
|
+
@trackers = []
|
|
137
|
+
@lock = Mutex.new
|
|
138
|
+
@started_at = UI.monotonic
|
|
139
|
+
@closing = false
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [void]
|
|
143
|
+
def start
|
|
144
|
+
# ratatui owns the terminal's input, so the keyboard is fed from its
|
|
145
|
+
# loop rather than reading STDIN on a thread of its own.
|
|
146
|
+
@keyboard = Keyboard.new(sink: @console)
|
|
147
|
+
@screen.attach_keyboard(@keyboard)
|
|
148
|
+
@screen.open
|
|
149
|
+
@painter = Thread.new do
|
|
150
|
+
Thread.current.report_on_exception = false
|
|
151
|
+
until @closing
|
|
152
|
+
paint
|
|
153
|
+
sleep(REFRESH)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# @return [void]
|
|
159
|
+
def stop
|
|
160
|
+
@closing = true
|
|
161
|
+
@painter&.join
|
|
162
|
+
paint
|
|
163
|
+
@screen.close
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# @param key [String]
|
|
167
|
+
# @param fields [Hash]
|
|
168
|
+
# @param file [String]
|
|
169
|
+
# @param message [String]
|
|
170
|
+
# @param timeout [Integer, nil]
|
|
171
|
+
# @return [Tracker]
|
|
172
|
+
def track(key:, fields: {}, file: "", message: "starting", timeout: nil)
|
|
173
|
+
Tracker.new(key:, fields:, file:, message:, timeout:).tap do |tracker|
|
|
174
|
+
@lock.synchronize { @trackers << tracker }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# @return [void]
|
|
179
|
+
def paint = @console.paint(board)
|
|
180
|
+
|
|
181
|
+
# @return [Agentilda::Engine::Board]
|
|
182
|
+
def board
|
|
183
|
+
rows = visible.map(&:row)
|
|
184
|
+
running = rows.select(&:running?)
|
|
185
|
+
Engine::Board.new(started_at: @started_at,
|
|
186
|
+
status: Execution::Control.quit? ? :quitting : :running,
|
|
187
|
+
plans: rows.map(&:ordinal).reject(&:empty?).uniq,
|
|
188
|
+
up: rows.sum(&:up),
|
|
189
|
+
down: rows.sum(&:down),
|
|
190
|
+
rows:,
|
|
191
|
+
root: @root,
|
|
192
|
+
running: running.size,
|
|
193
|
+
live_up: running.sum(&:up),
|
|
194
|
+
live_down: running.sum(&:down))
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# @param key [String]
|
|
198
|
+
# @return [void]
|
|
199
|
+
def kill(key) = find(key)&.handle&.kill!
|
|
200
|
+
|
|
201
|
+
# @param key [String]
|
|
202
|
+
# @param seconds [Integer]
|
|
203
|
+
# @return [void]
|
|
204
|
+
def extend(key, seconds) = find(key)&.handle&.extend!(seconds)
|
|
205
|
+
|
|
206
|
+
private
|
|
207
|
+
|
|
208
|
+
# @return [Array<Tracker>] running, plus whatever finished a moment ago
|
|
209
|
+
def visible
|
|
210
|
+
now = UI.monotonic
|
|
211
|
+
@lock.synchronize do
|
|
212
|
+
@trackers.reject { |t| t.finished_at && now - t.finished_at > LINGER }
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# @param key [String]
|
|
217
|
+
# @return [Tracker, nil]
|
|
218
|
+
def find(key) = @lock.synchronize { @trackers.find { |t| t.key == key } }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module Presentation
|
|
5
|
+
# `agentilda states` — the state machine as a terminal picture.
|
|
6
|
+
#
|
|
7
|
+
# {Documentation} already renders this machine as a Markdown table and a
|
|
8
|
+
# mermaid block, for a browser. This is the same machine, walked the same
|
|
9
|
+
# way, shaped for a terminal instead: unicode arrows, no browser required.
|
|
10
|
+
#
|
|
11
|
+
# Every section below is read off {StateMachine} and {STATUSES} rather than
|
|
12
|
+
# drawn by hand, for the reason {Documentation} gives for doing the same
|
|
13
|
+
# thing: three hand-maintained pictures of this machine have already
|
|
14
|
+
# drifted apart once, and a fourth is not the fix.
|
|
15
|
+
class Diagram
|
|
16
|
+
include UI
|
|
17
|
+
|
|
18
|
+
# Drawn between two states on the same line.
|
|
19
|
+
ARROW = "──▶"
|
|
20
|
+
|
|
21
|
+
# @return [String] the whole diagram, newline-terminated
|
|
22
|
+
def render
|
|
23
|
+
[
|
|
24
|
+
heading,
|
|
25
|
+
spine_section,
|
|
26
|
+
rejoin_section,
|
|
27
|
+
other_section,
|
|
28
|
+
terminal_section,
|
|
29
|
+
family_section
|
|
30
|
+
].compact.join("\n\n") + "\n"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# @return [String]
|
|
36
|
+
def heading
|
|
37
|
+
paint("Spec → Plan → Build — the state machine", :bold) + "\n" +
|
|
38
|
+
paint("(derived from lib/agentilda/state_machine.rb — `agentilda docs` for the Markdown form)",
|
|
39
|
+
:bright_black)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The path a bare `promote!` walks: {StateMachine::SPINE}, followed from
|
|
43
|
+
# `:new` until a state has no entry left to follow.
|
|
44
|
+
#
|
|
45
|
+
# @return [String]
|
|
46
|
+
def spine_section
|
|
47
|
+
section("MAIN SPINE",
|
|
48
|
+
"a bare `promote` walks this path",
|
|
49
|
+
[" " + spine_chain.map { |key| node(key) }.join(" #{ARROW} ")])
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# {StateMachine::SPINE} entries whose source never appears on the walk
|
|
53
|
+
# above — states that sit off the spine but whose default `promote!`
|
|
54
|
+
# rejoins it, e.g. a rejected pull request resubmitted for review.
|
|
55
|
+
#
|
|
56
|
+
# @return [String, nil]
|
|
57
|
+
def rejoin_section
|
|
58
|
+
rows = Plans::StateMachine::SPINE
|
|
59
|
+
.except(*spine_chain)
|
|
60
|
+
.map { |from, to| " #{node(from)} #{ARROW} #{node(to)}" }
|
|
61
|
+
|
|
62
|
+
section("REJOINING THE SPINE", "off-spine states whose default promotion lands back on it", rows)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Everything {StateMachine} allows that the two sections above do not
|
|
66
|
+
# already draw: blocking, deferring, discarding, review outcomes,
|
|
67
|
+
# rollback — read off {StateMachine.outbound} state by state, in
|
|
68
|
+
# {STATUSES} order, so nothing the machine permits is left undrawn.
|
|
69
|
+
#
|
|
70
|
+
# @return [String, nil]
|
|
71
|
+
def other_section
|
|
72
|
+
drawn = Plans::StateMachine::SPINE.to_a
|
|
73
|
+
|
|
74
|
+
groups = Plans::STATUSES.filter_map { |s|
|
|
75
|
+
targets = Plans::StateMachine.outbound(s.key).reject { |to| drawn.include?([s.key, to]) }
|
|
76
|
+
[s.key, targets] unless targets.empty?
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
section("OTHER TRANSITIONS",
|
|
80
|
+
"everything else the machine permits",
|
|
81
|
+
groups.flat_map { |key, targets|
|
|
82
|
+
fan_out(key, targets)
|
|
83
|
+
})
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [String, nil]
|
|
87
|
+
def terminal_section
|
|
88
|
+
rows = Plans::STATUSES.select(&:terminal?).map { |s| " #{node(s.key)}" }
|
|
89
|
+
section("TERMINAL", "nothing follows these", rows)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# {StateMachine::FAMILIES} share an invariant on purpose, and a folder's
|
|
93
|
+
# contents alone cannot tell their members apart — worth saying next to a
|
|
94
|
+
# diagram, or the members read like an oversight rather than a rule.
|
|
95
|
+
#
|
|
96
|
+
# @return [String, nil]
|
|
97
|
+
def family_section
|
|
98
|
+
rows = Plans::StateMachine::FAMILIES.map { |members| " #{members.map { |key| node(key) }.join(" / ")}" }
|
|
99
|
+
|
|
100
|
+
section("LOOK-ALIKE FAMILIES", "same contents on disk; only the folder name tells them apart", rows)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# `:new`, followed through {StateMachine::SPINE} until nothing follows.
|
|
104
|
+
#
|
|
105
|
+
# @return [Array<Symbol>]
|
|
106
|
+
def spine_chain
|
|
107
|
+
@spine_chain ||= [].tap { |chain|
|
|
108
|
+
key = :new
|
|
109
|
+
while key
|
|
110
|
+
chain << key
|
|
111
|
+
key = Plans::StateMachine::SPINE[key]
|
|
112
|
+
end
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# One state and its outbound arrows, drawn as a small tree so several
|
|
117
|
+
# targets from one source read as a fan-out rather than as unrelated
|
|
118
|
+
# lines that happen to repeat the source.
|
|
119
|
+
#
|
|
120
|
+
# @param key [Symbol]
|
|
121
|
+
# @param targets [Array<Symbol>]
|
|
122
|
+
# @return [Array<String>]
|
|
123
|
+
def fan_out(key, targets)
|
|
124
|
+
lines = targets.each_with_index.map { |to, i|
|
|
125
|
+
connector = i == targets.size - 1 ? "└─▶" : "├─▶"
|
|
126
|
+
" #{connector} #{node(to)}"
|
|
127
|
+
}
|
|
128
|
+
[" #{node(key)}", *lines]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @param key [Symbol]
|
|
132
|
+
# @return [String]
|
|
133
|
+
def node(key)
|
|
134
|
+
status = Plans::STATUS_BY_KEY.fetch(key)
|
|
135
|
+
paint("#{status.emoji} #{status.label}", :bold)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @param title [String]
|
|
139
|
+
# @param subtitle [String]
|
|
140
|
+
# @param rows [Array<String>]
|
|
141
|
+
# @return [String, nil] nil when there is nothing to show, so {#render} can drop it
|
|
142
|
+
def section(title, subtitle, rows)
|
|
143
|
+
return nil if rows.empty?
|
|
144
|
+
|
|
145
|
+
"#{paint(title, :bold)} #{paint("(#{subtitle})", :bright_black)}\n\n#{rows.join("\n")}"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|