fun_ci 2.1.1-aarch64-linux → 2.3.0-aarch64-linux

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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -0
  3. data/README.md +36 -6
  4. data/lib/fun_ci/agent/cancel_command.rb +52 -0
  5. data/lib/fun_ci/agent/command_support.rb +7 -0
  6. data/lib/fun_ci/agent/commands.rb +4 -1
  7. data/lib/fun_ci/agent/due_in.rb +21 -0
  8. data/lib/fun_ci/agent/events_command.rb +14 -7
  9. data/lib/fun_ci/agent/job_events.rb +56 -0
  10. data/lib/fun_ci/agent/job_json.rb +25 -0
  11. data/lib/fun_ci/agent/job_report.rb +89 -0
  12. data/lib/fun_ci/agent/job_why.rb +59 -0
  13. data/lib/fun_ci/agent/job_why_json.rb +31 -0
  14. data/lib/fun_ci/agent/job_why_text.rb +59 -0
  15. data/lib/fun_ci/agent/jobs_command.rb +43 -0
  16. data/lib/fun_ci/agent/jobs_text.rb +55 -0
  17. data/lib/fun_ci/agent/live_pipeline.rb +14 -6
  18. data/lib/fun_ci/agent/options.rb +3 -3
  19. data/lib/fun_ci/agent/output.rb +14 -2
  20. data/lib/fun_ci/agent/snapshots.rb +20 -1
  21. data/lib/fun_ci/agent/span.rb +24 -0
  22. data/lib/fun_ci/agent/stage_summary.rb +10 -6
  23. data/lib/fun_ci/agent/starts_in.rb +14 -0
  24. data/lib/fun_ci/agent/status_command.rb +3 -2
  25. data/lib/fun_ci/agent/status_json.rb +1 -1
  26. data/lib/fun_ci/agent/status_text.rb +32 -6
  27. data/lib/fun_ci/agent/trunk_reading.rb +2 -1
  28. data/lib/fun_ci/agent/wait_command.rb +2 -1
  29. data/lib/fun_ci/agent/why_command.rb +6 -2
  30. data/lib/fun_ci/cli.rb +1 -1
  31. data/lib/fun_ci/cli_help.rb +4 -1
  32. data/lib/fun_ci/console/board_data.rb +26 -13
  33. data/lib/fun_ci/console/console_state.rb +12 -6
  34. data/lib/fun_ci/console/job_message.rb +39 -0
  35. data/lib/fun_ci/console/job_order.rb +23 -0
  36. data/lib/fun_ci/console/job_rows.rb +21 -0
  37. data/lib/fun_ci/console/key_handler.rb +35 -14
  38. data/lib/fun_ci/console/paging.rb +26 -0
  39. data/lib/fun_ci/console/view.rb +18 -9
  40. data/lib/fun_ci/evidence/settings.rb +15 -8
  41. data/lib/fun_ci/evidence/settings_check.rb +14 -3
  42. data/lib/fun_ci/jobs/due.rb +34 -0
  43. data/lib/fun_ci/jobs/due_jobs.rb +50 -0
  44. data/lib/fun_ci/jobs/folders.rb +50 -0
  45. data/lib/fun_ci/jobs/job.rb +31 -0
  46. data/lib/fun_ci/jobs/job_fork.rb +52 -0
  47. data/lib/fun_ci/jobs/job_run.rb +104 -0
  48. data/lib/fun_ci/jobs/locks.rb +30 -0
  49. data/lib/fun_ci/jobs/schedule.rb +25 -0
  50. data/lib/fun_ci/jobs/site.rb +17 -0
  51. data/lib/fun_ci/jobs/standings.rb +50 -0
  52. data/lib/fun_ci/jobs/state.rb +27 -0
  53. data/lib/fun_ci/jobs/wall_clock_wait.rb +29 -0
  54. data/lib/fun_ci/persistence/active_jobs.rb +60 -0
  55. data/lib/fun_ci/persistence/database.rb +14 -4
  56. data/lib/fun_ci/persistence/job_recorder.rb +47 -0
  57. data/lib/fun_ci/persistence/job_runs.rb +80 -0
  58. data/lib/fun_ci/persistence/pipeline_run.rb +2 -1
  59. data/lib/fun_ci/persistence/raw_outputs.rb +3 -0
  60. data/lib/fun_ci/pipeline/budgets.rb +9 -0
  61. data/lib/fun_ci/pipeline/pipeline_forker.rb +42 -10
  62. data/lib/fun_ci/pipeline/priorities.rb +23 -0
  63. data/lib/fun_ci/pipeline/process_runner.rb +18 -6
  64. data/lib/fun_ci/pipeline/progress_reporter.rb +4 -0
  65. data/lib/fun_ci/pipeline/run_canceller.rb +24 -0
  66. data/lib/fun_ci/pipeline/slot.rb +12 -1
  67. data/lib/fun_ci/pipeline/slot_run.rb +19 -4
  68. data/lib/fun_ci/pipeline/stage_execution.rb +9 -4
  69. data/lib/fun_ci/pipeline/trigger.rb +17 -6
  70. data/lib/fun_ci/pipeline/trigger_command.rb +12 -2
  71. data/lib/fun_ci/pipeline/trigger_params.rb +20 -9
  72. data/lib/fun_ci/pipeline/worktree_pool.rb +2 -2
  73. data/lib/fun_ci/pipeline/worktree_prune.rb +8 -6
  74. data/lib/fun_ci/pipeline/worktrees.rb +11 -5
  75. data/lib/fun_ci/setup/agent_instructions.rb +24 -5
  76. data/lib/fun_ci/setup/commands.rb +3 -1
  77. data/lib/fun_ci/setup/hook_script.rb +9 -4
  78. data/lib/fun_ci/setup/hook_writer.rb +13 -5
  79. data/lib/fun_ci/setup/installed_hooks.rb +45 -0
  80. data/lib/fun_ci/setup/installer.rb +3 -2
  81. data/lib/fun_ci/setup/lfs_hook.rb +50 -0
  82. data/lib/fun_ci/setup/project_config.rb +13 -1
  83. data/lib/fun_ci/setup/settings.rb +40 -11
  84. data/lib/fun_ci/setup/setup_checker.rb +20 -3
  85. data/lib/fun_ci/trunk/shown.rb +7 -4
  86. data/lib/fun_ci/version.rb +1 -1
  87. data/libexec/fun-ci-renderer +0 -0
  88. metadata +36 -1
@@ -2,12 +2,14 @@
2
2
 
3
3
  require_relative "run_message"
4
4
  require_relative "console_events"
5
+ require_relative "job_message"
5
6
 
6
7
  module FunCi
7
8
  module Console
8
9
  # What the console shows: the page of runs BoardData reads that the View
9
- # puts on screen, and the clock's time, as a protocol `board` message,
10
- # after the `event`s for what changed since the last one.
10
+ # puts on screen, the daily and weekly jobs below it, and the clock's
11
+ # time, as a protocol `board` message, after the `event`s for what changed
12
+ # since the last one.
11
13
  class ConsoleState
12
14
  KEYS = { "up" => :up, "down" => :down, "esc" => :escape, "ctrl_c" => "q", "enter" => :enter }.freeze
13
15
 
@@ -26,18 +28,22 @@ module FunCi
26
28
 
27
29
  def updates
28
30
  @board_data.record_dead_slow_suites
31
+ @board_data.record_dead_jobs
29
32
  runs = @board_data.runs
30
- [*@events.since_last(runs), board(runs)]
33
+ [*@events.since_last(runs), board(runs, @board_data.jobs(runs))]
31
34
  end
32
35
 
33
36
  private
34
37
 
35
- def board(runs)
36
- page = @view.page(runs, more: @board_data.more?)
38
+ def board(runs, jobs)
39
+ page = @view.page(runs, more: @board_data.more?, jobs: jobs)
37
40
  { t: "board", now: @clock.call.to_i, streak: @board_data.streak, **page,
38
- runs: page[:runs].map { |run| RunMessage.from(run) }, **stale_trunks(runs) }
41
+ runs: page[:runs].map { |run| RunMessage.from(run) }, **job_section(jobs), **stale_trunks(runs) }
39
42
  end
40
43
 
44
+ # Only when there are jobs, as the protocol lets a board leave them out.
45
+ def job_section(jobs) = jobs.empty? ? {} : { jobs: jobs.map { |job| JobMessage.from(job) } }
46
+
41
47
  # Only when a trunk is stale, as the protocol lets a board leave it out.
42
48
  def stale_trunks(runs)
43
49
  stale = @board_data.stale_trunks(runs, now: @clock.call)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module FunCi
6
+ module Console
7
+ # Where a job stands (Jobs::Standing) as the protocol's `board` carries it
8
+ # under `jobs`: protocol status names and epoch seconds. Formatting them
9
+ # is the renderer's job.
10
+ module JobMessage
11
+ # The protocol's word for a Jobs::State, where it has one of its own:
12
+ # a cancelled job is due again, and one it can't read is shown due.
13
+ STATUS = { "over_budget" => "timeout", "cancelled" => "due", "unknown" => "due" }.freeze
14
+
15
+ def self.from(standing)
16
+ { project: standing.project, name: standing.name, cadence: standing.cadence,
17
+ status: STATUS.fetch(standing.state, standing.state), **run(standing.run, standing.starts_at),
18
+ due_at: standing.due_at&.to_i }.compact
19
+ end
20
+
21
+ # What the latest run says: which, on what, and when; a run waiting its
22
+ # turn (Jobs::Schedule) has not started, and says when it starts.
23
+ def self.run(run, starts_at)
24
+ return {} unless run
25
+
26
+ { run_id: run[:id], sha: run[:commit_hash], branch: run[:branch], **times(run, starts_at) }
27
+ end
28
+
29
+ def self.times(run, starts_at)
30
+ return { starts_at: starts_at.to_i } if starts_at
31
+
32
+ { started_at: epoch(run[:started_at]), updated_at: epoch(run[:completed_at] || run[:started_at]) }
33
+ end
34
+
35
+ def self.epoch(iso) = Time.parse(iso).to_i
36
+ private_class_method :run, :times, :epoch
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Console
5
+ # The order of the job section's rows (design.md, The console): what
6
+ # needs you first, then running, waiting its turn to (Jobs::Schedule),
7
+ # due and passed; among equals, the
8
+ # board's projects in its order, then each project's jobs by name. Rows
9
+ # answer #state (Jobs::State), #project and #name.
10
+ module JobOrder
11
+ URGENCY = { "failed" => 0, "lost" => 0, "over_budget" => 1, "running" => 2, "scheduled" => 3, "due" => 4,
12
+ "cancelled" => 4, "passed" => 5 }.freeze
13
+ # A state this fun-ci doesn't know, which a newer one sharing the database may have written.
14
+ UNKNOWN = 6
15
+
16
+ def self.of(rows, projects:)
17
+ rows.sort_by do |row|
18
+ [URGENCY.fetch(row.state, UNKNOWN), projects.index(row.project) || projects.size, row.name]
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "job_order"
4
+ require_relative "../jobs/standings"
5
+
6
+ module FunCi
7
+ module Console
8
+ # The job section's rows (design.md, The console): where each daily and
9
+ # weekly job of the board's projects stands (Jobs::Standing), in JobOrder.
10
+ class JobRows
11
+ def initialize(db)
12
+ @db = db
13
+ end
14
+
15
+ def of(projects, now:)
16
+ standings = projects.flat_map { |project| Jobs::Standings.new(@db, project, now: now).all }
17
+ JobOrder.of(standings, projects: projects)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -2,9 +2,10 @@
2
2
 
3
3
  module FunCi
4
4
  module Console
5
- # What a key means for the runs BoardData loads: moving the cursor,
6
- # quitting, and cancelling the run under the cursor (a running one after
7
- # the user confirms with `y`).
5
+ # What a key means for the runs BoardData loads, and the daily and weekly
6
+ # jobs after them: moving the cursor through both, quitting, and
7
+ # cancelling the run or job under the cursor (a running one after the
8
+ # user confirms with `y`).
8
9
  class KeyHandler
9
10
  CONFIRMATION_ANSWERS = ["y", "n", :escape].freeze
10
11
 
@@ -27,10 +28,6 @@ module FunCi
27
28
  !@confirm_cancel.nil?
28
29
  end
29
30
 
30
- def confirmation_run
31
- @confirm_cancel
32
- end
33
-
34
31
  private
35
32
 
36
33
  def act(key)
@@ -41,15 +38,16 @@ module FunCi
41
38
  end
42
39
  end
43
40
 
44
- # Onto the last run loaded, which loads the next page.
41
+ # Onto the last run loaded, which loads the next page, then into the jobs.
45
42
  def move_cursor_down
46
- last = @board_data.runs.length - 1
43
+ runs = @board_data.runs
44
+ last = runs.length + @board_data.jobs(runs).length - 1
47
45
  return if last.negative?
48
46
  return @cursor_index = 0 if @cursor_index.nil?
49
47
  return unless @cursor_index < last
50
48
 
51
49
  @cursor_index += 1
52
- @board_data.load_more if @cursor_index == last
50
+ @board_data.load_more if @cursor_index == runs.length - 1
53
51
  end
54
52
 
55
53
  def move_cursor_up
@@ -57,17 +55,40 @@ module FunCi
57
55
  end
58
56
 
59
57
  def initiate_cancel
60
- run = @cursor_index && @board_data.runs[@cursor_index]
61
- return unless run
58
+ kind, row = under_cursor
59
+ return unless row
62
60
 
61
+ kind == :job ? offer_job_cancel(row) : offer_run_cancel(row)
62
+ end
63
+
64
+ # A run waiting to start is cancelled at once; a running one once the user confirms.
65
+ def offer_run_cancel(run)
63
66
  @board_data.cancel_run(run[:id]) if run[:status] == "scheduled"
64
- @confirm_cancel = run if run[:status] == "running"
67
+ @confirm_cancel = [:run, run] if run[:status] == "running"
68
+ end
69
+
70
+ # A job (a Jobs::Standing) waiting its turn is cancelled at once; a
71
+ # running one once the user confirms.
72
+ def offer_job_cancel(job)
73
+ cancel(:job, job) if job.state == "scheduled"
74
+ @confirm_cancel = [:job, job] if job.state == "running"
75
+ end
76
+
77
+ # [:run, the run] or [:job, the job row] under the cursor, or none.
78
+ def under_cursor
79
+ runs = @board_data.runs
80
+ return [] unless @cursor_index
81
+ return [:run, runs[@cursor_index]] if @cursor_index < runs.length
82
+
83
+ [:job, @board_data.jobs(runs)[@cursor_index - runs.length]]
65
84
  end
66
85
 
67
86
  def confirm(key)
68
- @board_data.cancel_run(@confirm_cancel[:id]) if key == "y"
87
+ cancel(*@confirm_cancel) if key == "y"
69
88
  @confirm_cancel = nil if CONFIRMATION_ANSWERS.include?(key)
70
89
  end
90
+
91
+ def cancel(kind, row) = kind == :job ? @board_data.cancel_job(row.run[:id]) : @board_data.cancel_run(row[:id])
71
92
  end
72
93
  end
73
94
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Console
5
+ # How many branches BoardData loads: a page of `page_size` at first, a
6
+ # page more each time the cursor reaches the last one loaded.
7
+ class Paging
8
+ attr_reader :limit
9
+
10
+ def initialize(page_size)
11
+ @page_size = page_size
12
+ @limit = page_size
13
+ end
14
+
15
+ def load_more
16
+ @limit += @page_size
17
+ end
18
+
19
+ # Pages by `page_size` from now on, loading at least one such page.
20
+ def resize(page_size)
21
+ @page_size = page_size
22
+ @limit = [@limit, page_size].max
23
+ end
24
+ end
25
+ end
26
+ end
@@ -11,6 +11,8 @@ module FunCi
11
11
  # table: a page this many rows short of the terminal fits one line per
12
12
  # row, however the renderer spaces them (renderer-protocol.md, `board`).
13
13
  CHROME_ROWS = 18
14
+ # A blank line and one for the job section, the least the renderer folds it to.
15
+ JOB_LINES = 2
14
16
 
15
17
  def initialize(key_handler:)
16
18
  @key_handler = key_handler
@@ -25,23 +27,30 @@ module FunCi
25
27
  # :quit when the key ends the session.
26
28
  def press(key) = @key_handler.handle_key(key)
27
29
 
28
- # `more`: whether the store holds runs beyond `runs`.
29
- def page(runs, more:)
30
- first = first_shown
31
- { cursor: cursor_on_page(first), confirming: @key_handler.confirming?,
32
- has_more: more || first + @page_size < runs.size, runs: runs[first, @page_size] || [] }
30
+ # `more`: whether the store holds runs beyond `runs`. `jobs`: the job
31
+ # section's rows, below the page, which the cursor reaches after the runs.
32
+ def page(runs, more:, jobs: [])
33
+ size = jobs.empty? ? @page_size : [@page_size - JOB_LINES, 0].max
34
+ first = first_shown(runs.size, size)
35
+ { cursor: cursor_on_page(first, size), confirming: @key_handler.confirming?,
36
+ has_more: more || first + size < runs.size, runs: runs[first, size] || [] }
33
37
  end
34
38
 
35
39
  private
36
40
 
37
- def first_shown
41
+ # The last page of runs while the cursor is on a job.
42
+ def first_shown(count, size)
38
43
  cursor = @key_handler.cursor_index
39
- cursor ? [cursor - @page_size + 1, 0].max : 0
44
+ return 0 unless cursor
45
+
46
+ [[cursor, count - 1].min - size + 1, 0].max
40
47
  end
41
48
 
42
- def cursor_on_page(first)
49
+ # Past the page's runs while the cursor is on a job: a page that holds
50
+ # a job's cursor ends at the last run.
51
+ def cursor_on_page(first, size)
43
52
  cursor = @key_handler.cursor_index
44
- cursor - first if cursor && @page_size.positive?
53
+ cursor - first if cursor && size.positive?
45
54
  end
46
55
  end
47
56
  end
@@ -4,6 +4,7 @@ require "yaml"
4
4
  require_relative "catalog"
5
5
  require_relative "patterns"
6
6
  require_relative "settings_check"
7
+ require_relative "../jobs/job"
7
8
 
8
9
  module FunCi
9
10
  module Evidence
@@ -13,11 +14,11 @@ module FunCi
13
14
  # what is wrong, for `fun-ci check`.
14
15
  class Settings
15
16
  DEFAULTS = { "budget" => 2, "detect" => true, "skip" => [], "mask" => [], "masking" => true,
16
- "stages" => {} }.freeze
17
+ "stages" => {}, "jobs" => {} }.freeze
17
18
 
18
19
  # The settings in the config file at `path`, or the defaults without one.
19
20
  def self.load(path)
20
- new(File.exist?(path) ? (YAML.safe_load_file(path) || {})["evidence"] : nil)
21
+ new(File.exist?(path) ? (YAML.safe_load_file(path, aliases: true) || {})["evidence"] : nil)
21
22
  rescue Psych::Exception, NoMethodError, TypeError
22
23
  new(nil)
23
24
  end
@@ -36,8 +37,12 @@ module FunCi
36
37
  .then { |sources| Patterns.compile(sources) }
37
38
  end
38
39
 
39
- # The raw entries for `stage`: those for every stage, then its own.
40
- def entries(stage) = stage_list("all") + stage_list(stage)
40
+ # The raw entries for `stage`: those for every stage, then its own; for
41
+ # a job (`jobs/<name>`), those under `jobs:`.
42
+ def entries(stage)
43
+ job = Jobs::Job.named_by(stage)
44
+ stage_list("all") + (job ? list("jobs", job) : stage_list(stage))
45
+ end
41
46
 
42
47
  # The globs whose files are stamped when the stage starts, so what it
43
48
  # wrote to them can be told from what was there before.
@@ -54,10 +59,12 @@ module FunCi
54
59
  def values = @raw.is_a?(Hash) ? DEFAULTS.merge(@raw) : DEFAULTS
55
60
  def value(key) = values[key]
56
61
 
57
- def stage_list(stage)
58
- stages = value("stages")
59
- list = stages[stage] if stages.is_a?(Hash)
60
- list.is_a?(Array) ? list : []
62
+ def stage_list(stage) = list("stages", stage)
63
+
64
+ def list(setting, name)
65
+ lists = value(setting)
66
+ found = lists[name] if lists.is_a?(Hash)
67
+ found.is_a?(Array) ? found : []
61
68
  end
62
69
  end
63
70
  end
@@ -8,7 +8,7 @@ module FunCi
8
8
  # What is wrong with the `evidence` key of .fun-ci/config, each as a line
9
9
  # `fun-ci check` prints.
10
10
  class SettingsCheck
11
- KNOWN = %w[budget detect skip mask masking stages].freeze
11
+ KNOWN = %w[budget detect skip mask masking stages jobs].freeze
12
12
  STAGES = %w[all lint build fast slow].freeze
13
13
 
14
14
  # "2", 2, 2.5 or "2s" as seconds, or nil.
@@ -28,7 +28,7 @@ module FunCi
28
28
  return [] if @raw.nil?
29
29
  return ["evidence must be a mapping, not #{@raw.inspect}"] unless @raw.is_a?(Hash)
30
30
 
31
- unknown + budget + stages
31
+ unknown + budget + stages + jobs
32
32
  end
33
33
 
34
34
  private
@@ -48,12 +48,23 @@ module FunCi
48
48
  lists.flat_map { |stage, entries| stage_errors(stage, entries) }
49
49
  end
50
50
 
51
+ def jobs
52
+ lists = @raw.fetch("jobs", {})
53
+ return ["evidence.jobs must be a mapping of job to entries"] unless lists.is_a?(Hash)
54
+
55
+ lists.flat_map { |job, entries| entry_errors("evidence.jobs.#{job}", entries) }
56
+ end
57
+
58
+ def entry_errors(where, entries)
59
+ Array(entries).filter_map { |entry| refusal(entry) }.map { |message| "#{where}: #{message}" }
60
+ end
61
+
51
62
  def stage_errors(stage, entries)
52
63
  unless STAGES.include?(stage)
53
64
  return ["evidence.stages has no stage '#{stage}': use all, lint, build, fast or slow"]
54
65
  end
55
66
 
56
- Array(entries).filter_map { |entry| refusal(entry) }.map { |message| "evidence.stages.#{stage}: #{message}" }
67
+ entry_errors("evidence.stages.#{stage}", entries)
57
68
  end
58
69
 
59
70
  def refusal(entry)
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module FunCi
6
+ module Jobs
7
+ # Whether a job is due, from its latest run alone ({ status:, started_at: },
8
+ # or nil when it never ran): when it never ran, when that run was
9
+ # cancelled, or a period after that run started, unless it still runs or
10
+ # waits to (design.md, Daily and weekly jobs).
11
+ class Due
12
+ def initialize(latest, period, now:)
13
+ @latest = latest
14
+ @period = period
15
+ @now = now
16
+ end
17
+
18
+ def now?
19
+ return true if @latest.nil? || @latest[:status] == "cancelled"
20
+
21
+ !running? && again <= @now
22
+ end
23
+
24
+ # When it is due again, or nil while it is due now or still runs.
25
+ def at = now? || running? ? nil : again
26
+
27
+ private
28
+
29
+ # A run waiting its turn (Schedule) counts as running: it is the job's run.
30
+ def running? = %w[running scheduled].include?(@latest[:status])
31
+ def again = Time.parse(@latest[:started_at]) + @period
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "standings"
5
+ require_relative "schedule"
6
+ require_relative "../pipeline/run_canceller"
7
+ require_relative "../setup/project_config"
8
+
9
+ module FunCi
10
+ module Jobs
11
+ # The project's jobs that are due at `now`, read from the database without
12
+ # taking any lock: who starts a job is decided again, under its lock, by
13
+ # the process that runs it. A job left running by a process that died is
14
+ # recorded failed first, or it would never be due again, and no process
15
+ # would start to find it dead.
16
+ class DueJobs
17
+ BEGUN = %w[running scheduled].freeze
18
+ DAY = 86_400
19
+
20
+ def initialize(project, db, now:)
21
+ @project = project
22
+ @db = db
23
+ @now = now
24
+ end
25
+
26
+ def list = standings.select(&:due_now?).map(&:job)
27
+
28
+ # [job, when its turn to start comes] for each due job (Schedule): a day
29
+ # shared evenly among the project's jobs apart, or its `job_spacing`,
30
+ # after its jobs still running or waiting to. Each is due again a
31
+ # period after its own start, so the spread holds from day to day.
32
+ def starts
33
+ all = standings
34
+ Schedule.new(spacing(all.size), begun(all), now: @now).starts(all.select(&:due_now?).map(&:job))
35
+ end
36
+
37
+ private
38
+
39
+ def spacing(jobs) = Setup::ProjectConfig.new(@project).job_spacing || (DAY / [jobs, 1].max)
40
+
41
+ # When each of the project's jobs still running or waiting to began, or is to.
42
+ def begun(all) = all.select { |one| BEGUN.include?(one.state) }.map { |one| Time.parse(one.run[:started_at]) }
43
+
44
+ def standings
45
+ Pipeline::RunCanceller.new.record_dead_jobs(@db)
46
+ Standings.new(@db, @project, now: @now).all
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "job"
4
+
5
+ module FunCi
6
+ module Jobs
7
+ # The jobs a project keeps in .fun-ci/daily/ and .fun-ci/weekly/: each
8
+ # executable `<name>.sh`. A script that can't run, or a name in both
9
+ # folders, is no job; #errors says why, for `fun-ci check`.
10
+ class Folders
11
+ CADENCES = Job::PERIODS.keys
12
+ # A name a command line keeps whole, since agents paste it: fun-ci why --job NAME.
13
+ NAME = /\A[A-Za-z0-9._-]+\z/
14
+
15
+ def initialize(project_root)
16
+ @fun_ci_dir = File.join(project_root, ".fun-ci")
17
+ end
18
+
19
+ # By name.
20
+ def jobs
21
+ all = scripts
22
+ twice = twice(all)
23
+ all.select { |job| runnable?(job) && NAME.match?(job.name) && !twice.include?(job.name) }.sort_by(&:name)
24
+ end
25
+
26
+ def errors
27
+ all = scripts
28
+ all.reject { |job| runnable?(job) }.map { |job| "#{path(job)} is not executable" } +
29
+ all.reject { |job| NAME.match?(job.name) }.map { |job| misnamed(job) } +
30
+ twice(all).map { |name| "the job #{name} is in both .fun-ci/daily/ and .fun-ci/weekly/: rename one" }
31
+ end
32
+
33
+ private
34
+
35
+ def runnable?(job) = File.executable?(job.script)
36
+ def path(job) = ".fun-ci/#{job.cadence}/#{job.name}.sh"
37
+ def misnamed(job) = "the job '#{job.name}' needs a name of letters, digits, '.', '_' and '-': rename #{path(job)}"
38
+
39
+ def twice(all) = all.group_by(&:name).select { |_, jobs| jobs.size > 1 }.keys.sort
40
+
41
+ def scripts
42
+ CADENCES.flat_map do |cadence|
43
+ Dir.glob("*.sh", base: File.join(@fun_ci_dir, cadence)).map do |file|
44
+ Job.new(name: File.basename(file, ".sh"), cadence: cadence, script: File.join(@fun_ci_dir, cadence, file))
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module FunCi
6
+ module Jobs
7
+ # A script a project runs at most once a day or once a week, named after
8
+ # it (design.md, Daily and weekly jobs). `cadence` is "daily" or "weekly".
9
+ Job = Data.define(:name, :cadence, :script)
10
+
11
+ class Job
12
+ PERIODS = { "daily" => 86_400, "weekly" => 604_800 }.freeze
13
+ # Every job's, the weekly ones' too.
14
+ BUDGET = 86_400
15
+ # How a job is named where a stage would be.
16
+ STAGE = "jobs/"
17
+
18
+ # The name of the job a stage name names, or nil for a pipeline stage's.
19
+ def self.named_by(stage) = (stage.delete_prefix(STAGE) if stage.start_with?(STAGE))
20
+
21
+ # Seconds from one run's start until the job is due again.
22
+ def period = PERIODS.fetch(cadence)
23
+
24
+ # What evidence knows it by: `evidence: jobs: <name>:` in .fun-ci/config.
25
+ def stage = "#{STAGE}#{name}"
26
+
27
+ # The shell command that runs it on a commit, its path quoted.
28
+ def command(sha) = "#{Shellwords.escape(script)} #{sha}"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "job_run"
4
+ require_relative "../persistence/database"
5
+ require_relative "../pipeline/worktrees"
6
+ require_relative "../pipeline/priorities"
7
+
8
+ module FunCi
9
+ module Jobs
10
+ # Starts a job in a process of its own (architecture.md, Daily and weekly
11
+ # jobs). The trigger that forks it exits when its pipeline ends, and the
12
+ # terminal of the commit may close; a job can run for hours, so it leads a
13
+ # session of its own, prints nowhere, and opens its own connection, since
14
+ # a child must not inherit one. `priorities` says what its script runs under.
15
+ class JobFork
16
+ def initialize(project:, db_path:, priorities: Pipeline::Priorities.for(RUBY_PLATFORM))
17
+ @project = project
18
+ @db_path = db_path
19
+ @priorities = priorities
20
+ end
21
+
22
+ # at: when the job's turn to start comes (Schedule); now when nil.
23
+ def start(job, commit, at: nil) = Process.detach(fork { run_in_child(job, commit, at) })
24
+
25
+ private
26
+
27
+ def run_in_child(job, commit, at)
28
+ Process.setsid
29
+ silence
30
+ db = Persistence::Database.connection(@db_path)
31
+ JobRun.new(job, commit, site(db), Pipeline::Seams.new(priorities: @priorities)).start(at: at)
32
+ ensure
33
+ db&.close
34
+ end
35
+
36
+ # The process's own descriptors, whatever $stdout and $stderr stand for
37
+ # (a test's StringIO, a guard's file), onto an open /dev/null, since
38
+ # reopening by path can't change a stream's access mode.
39
+ def silence
40
+ File.open(File::NULL, File::RDWR) do |null|
41
+ (0..2).each { |fd| IO.for_fd(fd, autoclose: false).reopen(null) }
42
+ end
43
+ end
44
+
45
+ def site(db)
46
+ worktrees = Pipeline::Worktrees.new(@project)
47
+ Site.new(project: @project, db: db, worktrees: worktrees, locks: Locks.new(worktrees.jobs_root),
48
+ clock: -> { Time.now })
49
+ end
50
+ end
51
+ end
52
+ end