fun_ci 2.2.0 → 2.3.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +15 -8
  4. data/lib/fun_ci/agent/cancel_command.rb +52 -0
  5. data/lib/fun_ci/agent/commands.rb +3 -1
  6. data/lib/fun_ci/agent/job_events.rb +26 -7
  7. data/lib/fun_ci/agent/job_json.rb +10 -1
  8. data/lib/fun_ci/agent/job_report.rb +10 -6
  9. data/lib/fun_ci/agent/job_why_text.rb +11 -3
  10. data/lib/fun_ci/agent/jobs_text.rb +8 -3
  11. data/lib/fun_ci/agent/live_pipeline.rb +7 -4
  12. data/lib/fun_ci/agent/snapshots.rb +5 -1
  13. data/lib/fun_ci/agent/starts_in.rb +14 -0
  14. data/lib/fun_ci/agent/status_json.rb +1 -1
  15. data/lib/fun_ci/agent/status_text.rb +15 -3
  16. data/lib/fun_ci/agent/trunk_reading.rb +2 -1
  17. data/lib/fun_ci/agent/wait_command.rb +2 -1
  18. data/lib/fun_ci/cli.rb +1 -1
  19. data/lib/fun_ci/cli_help.rb +2 -1
  20. data/lib/fun_ci/console/job_message.rb +12 -6
  21. data/lib/fun_ci/console/job_order.rb +5 -4
  22. data/lib/fun_ci/console/key_handler.rb +3 -1
  23. data/lib/fun_ci/evidence/settings.rb +1 -1
  24. data/lib/fun_ci/jobs/due.rb +4 -3
  25. data/lib/fun_ci/jobs/due_jobs.rb +26 -2
  26. data/lib/fun_ci/jobs/job_fork.rb +19 -12
  27. data/lib/fun_ci/jobs/job_run.rb +26 -7
  28. data/lib/fun_ci/jobs/schedule.rb +25 -0
  29. data/lib/fun_ci/jobs/site.rb +10 -3
  30. data/lib/fun_ci/jobs/standings.rb +4 -0
  31. data/lib/fun_ci/jobs/state.rb +3 -2
  32. data/lib/fun_ci/jobs/wall_clock_wait.rb +29 -0
  33. data/lib/fun_ci/persistence/active_jobs.rb +13 -7
  34. data/lib/fun_ci/persistence/job_runs.rb +12 -0
  35. data/lib/fun_ci/persistence/pipeline_run.rb +2 -1
  36. data/lib/fun_ci/pipeline/budgets.rb +9 -0
  37. data/lib/fun_ci/pipeline/pipeline_forker.rb +11 -8
  38. data/lib/fun_ci/pipeline/priorities.rb +23 -0
  39. data/lib/fun_ci/pipeline/process_runner.rb +7 -5
  40. data/lib/fun_ci/pipeline/progress_reporter.rb +4 -0
  41. data/lib/fun_ci/pipeline/slot.rb +11 -0
  42. data/lib/fun_ci/pipeline/slot_run.rb +19 -4
  43. data/lib/fun_ci/pipeline/trigger.rb +17 -6
  44. data/lib/fun_ci/pipeline/trigger_command.rb +6 -2
  45. data/lib/fun_ci/pipeline/trigger_params.rb +20 -9
  46. data/lib/fun_ci/pipeline/worktree_pool.rb +2 -2
  47. data/lib/fun_ci/pipeline/worktrees.rb +3 -1
  48. data/lib/fun_ci/setup/agent_instructions.rb +21 -5
  49. data/lib/fun_ci/setup/commands.rb +3 -1
  50. data/lib/fun_ci/setup/hook_script.rb +9 -4
  51. data/lib/fun_ci/setup/hook_writer.rb +13 -5
  52. data/lib/fun_ci/setup/installed_hooks.rb +45 -0
  53. data/lib/fun_ci/setup/installer.rb +3 -2
  54. data/lib/fun_ci/setup/lfs_hook.rb +50 -0
  55. data/lib/fun_ci/setup/project_config.rb +7 -1
  56. data/lib/fun_ci/setup/settings.rb +40 -11
  57. data/lib/fun_ci/setup/setup_checker.rb +14 -3
  58. data/lib/fun_ci/trunk/shown.rb +7 -4
  59. data/lib/fun_ci/version.rb +1 -1
  60. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 363808a79c7bf82b45f577fdc241e10ab866a11d2966fdc6b52ad583e1a35ce7
4
- data.tar.gz: 9932aa6e0b9b1c2cf506032bca183046e02b71ea583f4cd66affd1dbe2311df9
3
+ metadata.gz: b55c9059ad2ffd38aa87dd4dd9958631a14390bcae0e0e7577e78bd83d4c530f
4
+ data.tar.gz: 6c40537e0a4eb9425dc9eab279e22dec5fdfe0142bab07b49c47f290cdaceeb3
5
5
  SHA512:
6
- metadata.gz: a0b58e24f2d166894afa311971af5b42992535a9befbbbbdcdcd4a9afe6e6c3f7ff396d5c63bc9e31262c5e4b8a0fed95572bbca63b6f32749fcaba75beeee80
7
- data.tar.gz: 16be19fcf7a864a7d573f6b64bfbbad7024dd0bbfd371c0b515ca304202135d6f37046f7e09ad64b27606565e7ac71345a828dd761e195cbc61c1303107ee976
6
+ metadata.gz: 82576ff9731c282102710ad24d83b2ff3430a71bab671b33d6823dd507a47ec3855ccf912afbff706b9514d2f045313bd69461aa3076dbf85673b17e2db32920
7
+ data.tar.gz: 28c718c40751cf17bdc74bba6db5f8b07d4d9ecf889409b7861563302c22c10590f54f82680facbaf5d0000552d5ed8344831eff856fb9927acaf6f113383242
data/CHANGELOG.md CHANGED
@@ -7,6 +7,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.3.0] - 2026-10-02
11
+
12
+ ### Added
13
+ - A commit's due daily and weekly jobs take turns instead of starting
14
+ together, spread evenly over the day: with four jobs, the first at once
15
+ and each next one six hours later, and none sooner than that after a job
16
+ still running or waiting. `job_spacing:` in `.fun-ci/config` sets the gap
17
+ instead (`30m`, `1h`; `0` starts them together). A job waiting its turn
18
+ says when it starts: `starts in 6h` in the console and in `fun-ci jobs`,
19
+ `starts_at` in its JSON, `scheduled, starts in 6h` in `status`,
20
+ `job_scheduled` in `events`. `c` in the console cancels it at
21
+ once, as does `fun-ci cancel --job`. `fun-ci check` names a spacing it
22
+ can't read. The console needs renderer 2.3.0 to show a waiting job; an
23
+ older one says it is in a state it doesn't know.
24
+ - `fun-ci cancel --job NAME` stops a daily or weekly job's run, as `c` on its
25
+ row does in the console, so an agent can too. The job runs again on the
26
+ next commit.
27
+ - `fun-ci-renderer --version` and `--help`, for anyone who installed the
28
+ renderer with cargo. Before, each said it "needs a value".
29
+ - `fun-ci check` warns when the post-commit or pre-push hook doesn't run
30
+ fun-ci, instead of saying all is well while no commit is tested.
31
+
32
+ ### Changed
33
+ - In a worktree fun-ci has just made, as for the first runs after install or
34
+ `fun-ci prune`, lint and build get the slow suite's budget, since their
35
+ caches are empty: a cold build over 30 seconds no longer blocks the first
36
+ push. A run in the foreground says so, `fun-ci status` says `(new
37
+ worktree, budget 300s)` on their lines, and one that still runs over shows
38
+ the longer budget in `fun-ci why`. `status --json` gives each stage's
39
+ `budget`.
40
+ - Daily and weekly jobs run at a lower priority than the stages: on macOS
41
+ under `taskpolicy -c utility`, elsewhere at `nice -n 19`. Before, four due
42
+ jobs could leave a build a fraction of the cores and push it over its
43
+ budget. The slow suite runs at `nice -n 10` beside the fast suite, except
44
+ on macOS, where nice has no measurable effect.
45
+ - `fun-ci init` brings the fun-ci section of `AGENTS.md` (or `CLAUDE.md`) up
46
+ to date, so agents in a project set up with an older fun-ci learn about
47
+ jobs and the trunk. It replaces the section from its `## fun-ci` heading to
48
+ the next heading; before, it left any section it found as it was.
49
+
50
+ ### Fixed
51
+ - `fun-ci trigger` given a short SHA, or a revision such as `HEAD`, kept the
52
+ run under what it was given, so `fun-ci status`, `wait` and `why`, which
53
+ look runs up by the full SHA, never found it. Runs are kept under the full
54
+ SHA, and the daily and weekly jobs a run starts test that commit too.
55
+ - An agent that waited on each commit, as `fun-ci init` tells it to, kept
56
+ every run it made from being cancelled by the next, so their slow suites
57
+ ran side by side. `wait --follow-branch` no longer keeps its run going, and
58
+ the command a commit prints, which agents are told to run, follows the
59
+ branch. Run `fun-ci init` again to bring the section it wrote into
60
+ AGENTS.md up to date; the old one tells agents to act on `4 superseded`.
61
+ - `fun-ci status` said `trunk unknown, the check never finished` for a run
62
+ whose lint, build or fast suite took longer than 25 seconds, while the
63
+ check was only waiting for them to end before it was recorded.
64
+ - In a Git LFS repository, `fun-ci install-hooks` installed nothing, since
65
+ git-lfs had written post-commit and pre-push already, and no commit was
66
+ tested. It now writes hooks that run fun-ci's and then git-lfs's, giving
67
+ git-lfs the push's ref list as git sent it. In such a repository, run
68
+ `fun-ci install-hooks` again. `git lfs install` then says the hooks exist
69
+ and exits 2; they already run git-lfs's.
70
+ - A mistake `fun-ci check` names in `.fun-ci/config` (a wrong
71
+ `worktree_slots` or `job_spacing`, or YAML it can't read) no longer stops a
72
+ commit's run, which let the push through untested: the trigger names it,
73
+ the setting takes its default, and the run goes ahead. `fun-ci check` still
74
+ fails on it.
75
+ - YAML anchors and aliases in `.fun-ci/config` are read, so stages can share
76
+ one list of evidence entries. Before, an alias made `fun-ci check` and
77
+ `fun-ci trigger` die with a stack trace, and the evidence settings were
78
+ quietly dropped. YAML that holds a value settings can't (a date, say) is
79
+ named as the file's mistake instead of raised.
80
+
10
81
  ## [2.2.0] - 2026-10-01
11
82
 
12
83
  ### Added
data/README.md CHANGED
@@ -21,7 +21,7 @@ cd your-project
21
21
  fun-ci init --everything
22
22
  ```
23
23
 
24
- This detects your project type, writes four stage scripts into `.fun-ci/`, installs a `post-commit` and a `pre-push` git hook, and checks the setup. It also adds a short section to your `AGENTS.md` (or `CLAUDE.md`, if that is the only one) telling coding agents what to do after a commit.
24
+ This detects your project type, writes four stage scripts into `.fun-ci/`, installs a `post-commit` and a `pre-push` git hook, and checks the setup. It also adds a short section to your `AGENTS.md` (or `CLAUDE.md`, if that is the only one) telling coding agents what to do after a commit; run again after an upgrade, it brings that section up to date.
25
25
 
26
26
  `fun-ci init` has templates for Ruby, Gradle, Maven, Rust, Go, Elixir, Dart, Swift, PHP, .NET, Python, Deno, Bun, Node, Perl, and C or C++ built with CMake or make. [docs/stacks.md](docs/stacks.md) shows how it recognises each one and the commands it writes. Fun-CI works with any project: the stages are shell scripts, so edit them to run whatever your project uses.
27
27
 
@@ -49,7 +49,9 @@ A stage that overruns its budget is killed and reported as over budget, in yello
49
49
 
50
50
  After each commit, the `post-commit` hook starts the pipeline in the background and returns at once, so a commit is never held up. The `pre-push` hook waits for the verdict of each commit you push and stops the push if lint, build or the fast suite failed. A commit whose run has already finished goes straight through. If fun-ci isn't installed, both hooks say so and let git carry on.
51
51
 
52
- Each run happens in a git worktree of its own under `.git/fun-ci/worktrees/`, checked out at the commit it tests, so you can keep editing while it runs. Ignored files such as `vendor/bundle` or `node_modules` stay in a worktree from one run to the next, which keeps builds quick. Two runs can go at once; set `worktree_slots: 3` in `.fun-ci/config` for more, and `fun-ci prune` removes the worktrees when you want the space back. A newer commit on a branch cancels the older run that is still going, unless an agent is waiting on it.
52
+ `install-hooks` leaves a hook another tool wrote alone, and `fun-ci check` warns that no commit is tested until that hook calls fun-ci. Git LFS's own hooks are the exception: fun-ci writes its hooks in their place, and they run git-lfs's after fun-ci's, so LFS objects still reach the remote. After that, `git lfs install` says the hooks exist and exits 2, since it accepts no hook but its own; there is nothing for it to do. `git lfs update --force` would put git-lfs's back, leaving fun-ci out, until you run `fun-ci install-hooks` again.
53
+
54
+ Each run happens in a git worktree of its own under `.git/fun-ci/worktrees/`, checked out at the commit it tests, so you can keep editing while it runs. Ignored files such as `vendor/bundle` or `node_modules` stay in a worktree from one run to the next, which keeps builds quick. A worktree fun-ci has just made has none of them yet, so in its first run lint and build get the slow suite's five minutes rather than 30 seconds, and `fun-ci status` says so on their lines. Two runs can go at once; set `worktree_slots: 3` in `.fun-ci/config` for more, and `fun-ci prune` removes the worktrees when you want the space back. A newer commit on a branch cancels the older run that is still going, unless an agent is waiting on it without `--follow-branch`.
53
55
 
54
56
  Results are kept in SQLite under `$XDG_STATE_HOME/fun-ci/` (`~/.local/state/fun-ci/` by default), shared by every project on the machine.
55
57
 
@@ -64,11 +66,13 @@ Some checks take longer than the time between two commits: a mutation run, a soa
64
66
 
65
67
  The script's name is the job's name. Like a stage, it gets the commit's hash as its first argument and passes when it exits 0, and `FUN_CI_JOB` tells it which job it is. There is nothing else to configure.
66
68
 
67
- Commits start jobs. When the `post-commit` hook runs a pipeline, it also starts each of the project's jobs that is due, beside the pipeline, on the commit just made. A job is due when it has never run, when its last run was cancelled, or a day (daily) or a week (weekly) after its last run started. A project nobody commits to runs no jobs. Each job runs once at a time, in a worktree of its own under `.git/fun-ci/jobs/`, so it never holds up a commit's run, and has 24 hours before it is stopped. A newer commit never cancels a job. A job's result changes no run, no streak and no exit code.
69
+ Commits start jobs. When the `post-commit` hook runs a pipeline, it also starts the project's due jobs on the commit just made: a job is due when it has never run, when its last run was cancelled, or a day (daily) or a week (weekly) after its last run started, so a project nobody commits to runs none. The jobs take turns, spread evenly over the day: with four jobs, the first starts at once and each next one six hours later. `job_spacing: 30m` in `.fun-ci/config` sets the gap instead, and `0` starts them together. A job waiting its turn says when it starts.
70
+
71
+ Each job runs once at a time, in a worktree of its own under `.git/fun-ci/jobs/`, at a lower priority than the stages, so it takes only the cores they leave idle and never holds up a commit's run. It has 24 hours. A newer commit never cancels a job, and a job's result changes no run, no streak and no exit code. The [design](docs/design.md#daily-and-weekly-jobs) has the rest of the rules.
68
72
 
69
73
  A failed job keeps its evidence as a failed stage does; add entries under `evidence: jobs: <name>:` in `.fun-ci/config` to keep more. `fun-ci check` lists the jobs it found, and says which scripts it can't run.
70
74
 
71
- `fun-ci jobs` lists the jobs, each with how its last run went, on which commit, and when it is due again. `fun-ci why --job soak` prints everything kept about the job's last run (`--raw` for its whole output, `--json` for a document), and exits as `why` does for a stage. `fun-ci status` names the jobs whose last run tested the commit you ask about, and any job failing on another commit, and `fun-ci events` says when a job starts and finishes. None of them changes a commit's verdict. A job's name is letters, digits, `.`, `_` and `-`, so those commands can be pasted as they are printed.
75
+ `fun-ci jobs` lists the jobs, each with how its last run went, on which commit, and when it is due again. `fun-ci why --job soak` prints everything kept about the job's last run (`--raw` for its whole output, `--json` for a document), and exits as `why` does for a stage. `fun-ci status` names the jobs whose last run tested the commit you ask about, and any job failing on another commit, and `fun-ci events` says when a job is scheduled, starts and finishes. `fun-ci cancel --job soak` stops the job's run, as `c` does in the console, so it runs again on the next commit. None of them changes a commit's verdict. A job's name is letters, digits, `.`, `_` and `-`, so those commands can be pasted as they are printed.
72
76
 
73
77
  ## Watching: the console
74
78
 
@@ -114,7 +118,7 @@ A branch that conflicts with the trunk says so on the line under its name, `conf
114
118
  Keys:
115
119
 
116
120
  - `j` and `k`, or the arrow keys, move the cursor down and up
117
- - `c` cancels the run under the cursor: one waiting to start at once, a running one once you answer `y` (`n` or `Esc` keeps it running); the footer offers it only while a run is running or waits to start. The cursor moves on past the last branch into the jobs, and `c` cancels a running job the same way; a cancelled job runs again on your next commit
121
+ - `c` cancels the run under the cursor: one waiting to start at once, a running one once you answer `y` (`n` or `Esc` keeps it running); the footer offers it only while a run is running or waits to start. The cursor moves on past the last branch into the jobs, and `c` cancels a job too: one waiting its turn at once, a running one the same way; a cancelled job runs again on your next commit
118
122
  - `q` quits
119
123
 
120
124
  The console is drawn in 24-bit colour when `COLORTERM` says the terminal has it (`truecolor` or `24bit`), and in 256 colours otherwise. The pictures above are the renderer's output replayed in a terminal emulator, set in Menlo; in your terminal the text is in your terminal's font.
@@ -126,10 +130,10 @@ The drawing is done by a separate program, `fun-ci-renderer`, written in Rust. T
126
130
  Every commit's output ends with the command that gets its verdict:
127
131
 
128
132
  ```
129
- fun-ci: testing 3f9c2ab. Verdict: fun-ci wait 3f9c2ab --need all
133
+ fun-ci: testing 3f9c2ab. Verdict: fun-ci wait 3f9c2ab --need all --follow-branch
130
134
  ```
131
135
 
132
- The section `fun-ci init` writes into `AGENTS.md` tells an agent to run that command in the background after each commit. Agent harnesses wake the agent when a background command exits, so it hears about a failure without having to remember to ask.
136
+ The section `fun-ci init` writes into `AGENTS.md` tells an agent to run that command in the background after each commit. Agent harnesses wake the agent when a background command exits, so it hears about a failure without having to remember to ask. With `--follow-branch`, a newer commit on the branch cancels the older run, and the wait moves on to the newer run's verdict.
133
137
 
134
138
  `status` says where a commit's run stands and `wait` blocks until it is decided. Both exit with the verdict for the stages you need: `--need build` (lint and build), `fast` (the default) or `all` (the slow suite too).
135
139
 
@@ -231,6 +235,8 @@ evidence:
231
235
  on: overrun
232
236
  ```
233
237
 
238
+ Beside the entries, `evidence:` takes a few settings: `budget: 5` gives the extractors five seconds rather than two, `detect: false` stops fun-ci choosing presets from what the output shows, `skip: [rspec]` leaves out the presets named, `mask:` adds patterns of your own to mask, and `masking: false` turns masking off.
239
+
234
240
  `fun-ci extract fast --output saved.log` runs a stage's extractors against a saved output (`fun-ci why --raw > saved.log`), so you can try an entry without making a commit. A mistake under `evidence:` never stops a pipeline: `fun-ci check` reports it and `fun-ci why` names it.
235
241
 
236
242
  Secrets in the stage's environment are masked before anything is kept: the value of any variable whose name holds TOKEN, SECRET, PASSWORD, PASSWD, API_KEY, PRIVATE_KEY or CREDENTIAL, and GitHub, AWS and Slack tokens, private keys and `Authorization:` headers wherever they appear. The state directory is readable by your user alone.
@@ -245,11 +251,12 @@ fun-ci install-hooks post-commit Install a single hook type
245
251
  fun-ci check Verify .fun-ci/ setup
246
252
  fun-ci console Watch the runs
247
253
  fun-ci status [commit] [--trunk] Where a commit's run stands; the verdict is the exit code
248
- fun-ci wait [commit] Wait until that verdict is decided, then exit with it
254
+ fun-ci wait [commit] [--follow-branch] Wait until that verdict is decided, then exit with it
249
255
  fun-ci why [commit] [stage|trunk] Everything kept about why a stage failed, or the conflict with the trunk
250
256
  fun-ci runs This project's recent runs, newest first
251
257
  fun-ci jobs This project's daily and weekly jobs and how each stands
252
258
  fun-ci why --job <name> Everything kept about a job's latest run
259
+ fun-ci cancel --job <name> Stop a job's run; it runs again on the next commit
253
260
  fun-ci events [--follow] The runs' events as JSON lines
254
261
  fun-ci extract <stage> --output <file> Try a stage's extractors on a saved output
255
262
  fun-ci trigger <commit> <branch> Run the whole pipeline in the foreground
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command_support"
4
+ require_relative "../jobs/folders"
5
+ require_relative "../persistence/active_jobs"
6
+
7
+ module FunCi
8
+ module Agent
9
+ # `fun-ci cancel --job NAME` (acceptance-tests.md, AT-13.26): stops a
10
+ # daily or weekly job's running run, as `c` on its row in the console
11
+ # does, and records it cancelled, so it is due again on the next commit.
12
+ class CancelCommand
13
+ include CommandSupport
14
+
15
+ NAME = "cancel"
16
+
17
+ def initialize(context)
18
+ @context = context
19
+ end
20
+
21
+ def run(args)
22
+ name = Options.parse(args, takes: %i[job]).job || raise(Options::Invalid, "name the job: --job NAME")
23
+ raise Options::Invalid, no_job(name) unless names.include?(name)
24
+
25
+ cancel(name, Persistence::ActiveJobs.running_of(@context.db, project, name))
26
+ rescue Options::Invalid => e
27
+ usage(e.message)
28
+ end
29
+
30
+ private
31
+
32
+ def cancel(name, running)
33
+ return say("job #{name} is not running, so there is nothing to cancel.") if running.empty?
34
+
35
+ running.each { |id| @context.pipeline.cancel_job(@context.db, id) }
36
+ say("cancelled job #{name}; it runs again on the next commit.")
37
+ end
38
+
39
+ def say(message)
40
+ @context.io.stdout.puts "fun-ci: #{message}"
41
+ 0
42
+ end
43
+
44
+ def project = @context.git.toplevel
45
+ def names = Jobs::Folders.new(project).jobs.map(&:name)
46
+
47
+ def no_job(name)
48
+ names.empty? ? JobWhy::NO_JOBS : "no job '#{name}' in this project: its jobs are #{names.join(", ")}"
49
+ end
50
+ end
51
+ end
52
+ end
@@ -11,6 +11,7 @@ require_relative "wait_command"
11
11
  require_relative "events_command"
12
12
  require_relative "why_command"
13
13
  require_relative "jobs_command"
14
+ require_relative "cancel_command"
14
15
  require_relative "../trunk/local"
15
16
 
16
17
  module FunCi
@@ -19,7 +20,8 @@ module FunCi
19
20
  # database and the project's git.
20
21
  module Commands
21
22
  ALL = { "status" => StatusCommand, "runs" => RunsCommand, "wait" => WaitCommand,
22
- "events" => EventsCommand, "why" => WhyCommand, "jobs" => JobsCommand }.freeze
23
+ "events" => EventsCommand, "why" => WhyCommand, "jobs" => JobsCommand,
24
+ "cancel" => CancelCommand }.freeze
23
25
 
24
26
  def self.run(name, args, context)
25
27
  ALL.fetch(name).new(context).run(args)
@@ -5,14 +5,16 @@ require_relative "../jobs/state"
5
5
  module FunCi
6
6
  module Agent
7
7
  # What happened to a project's daily and weekly job runs between two
8
- # looks at them (acceptance-tests.md, AT-13.23), oldest first: a job run
9
- # started, and ended with its state and seconds. The first look is at nothing.
8
+ # looks at them (acceptance-tests.md, AT-13.23, AT-13.28), oldest first: a
9
+ # job run was scheduled to start later (Jobs::Schedule), started, and
10
+ # ended with its state and seconds. The first look is at nothing.
10
11
  module JobEvents
11
- # state: as Jobs::State says it; seconds: how long it ran, once it ended.
12
- JobState = Data.define(:id, :job, :cadence, :sha, :branch, :state, :seconds)
12
+ # state: as Jobs::State says it; seconds: how long it ran, once it ended;
13
+ # starts_at: when a run waiting its turn starts, nil for any other.
14
+ JobState = Data.define(:id, :job, :cadence, :sha, :branch, :state, :seconds, :starts_at)
13
15
 
14
16
  # The states of a run that has not ended.
15
- GOING = %w[running due].freeze
17
+ GOING = %w[running due scheduled].freeze
16
18
 
17
19
  # before, after: { job run id => JobState }.
18
20
  def self.between(before, after)
@@ -23,7 +25,24 @@ module FunCi
23
25
 
24
26
  def self.of(run, was)
25
27
  about = { job: run.job, cadence: run.cadence, commit: run.sha, branch: run.branch }
26
- [*(was ? [] : [{ event: "job_started", **about }]), *ended(run, was, about)]
28
+ [*begun(run, was, about), *ended(run, was, about)]
29
+ end
30
+
31
+ # A run first seen waiting was scheduled; one that started since the
32
+ # last look started.
33
+ def self.begun(run, was, about)
34
+ return [{ event: "job_scheduled", **about, starts_at: run.starts_at }] if run.state == "scheduled" && !was
35
+
36
+ started?(run, was) ? [{ event: "job_started", **about }] : []
37
+ end
38
+
39
+ # First seen past waiting, or past it now after it waited; one
40
+ # cancelled while it waited never started.
41
+ def self.started?(run, was)
42
+ return false if run.state == "scheduled"
43
+ return true unless was
44
+
45
+ was.state == "scheduled" && run.state != "cancelled"
27
46
  end
28
47
 
29
48
  def self.ended(run, was, about)
@@ -31,7 +50,7 @@ module FunCi
31
50
 
32
51
  [{ event: "job_finished", **about, state: run.state, seconds: run.seconds }]
33
52
  end
34
- private_class_method :of, :ended
53
+ private_class_method :of, :begun, :started?, :ended
35
54
  end
36
55
  end
37
56
  end
@@ -9,8 +9,17 @@ module FunCi
9
9
  stage = report.stage
10
10
  { name: report.name, cadence: report.cadence, state: report.state,
11
11
  commit: stage && { sha: report.sha, branch: report.branch }, seconds: stage&.seconds,
12
- started_at: report.started_at&.utc&.iso8601, due_at: report.due_at&.utc&.iso8601, due: report.due? }
12
+ **times(report), due: report.due? }
13
13
  end
14
+
15
+ # When its latest run started, or, while it waits its turn, is to start
16
+ # (Jobs::Schedule), and when it is due again.
17
+ def self.times(report)
18
+ { started_at: iso(report.started_at), starts_at: iso(report.starts_at), due_at: iso(report.due_at) }
19
+ end
20
+
21
+ def self.iso(time) = time&.utc&.iso8601
22
+ private_class_method :times, :iso
14
23
  end
15
24
  end
16
25
  end
@@ -10,8 +10,9 @@ module FunCi
10
10
  module Agent
11
11
  # A daily or weekly job as an agent is told it: where it stands
12
12
  # (Jobs::Standing), and its latest run as a RunReport::Stage named after
13
- # the job, nil while it never ran.
14
- JobReport = Data.define(:standing, :stage)
13
+ # the job, nil while it never ran, and the seconds until a run waiting its
14
+ # turn starts (Jobs::Schedule), nil for any other.
15
+ JobReport = Data.define(:standing, :stage, :starts_in)
15
16
 
16
17
  # The jobs `status` names for a commit: those whose latest run tested it,
17
18
  # and those failing on another commit, which an agent would not hear of otherwise.
@@ -23,7 +24,7 @@ module FunCi
23
24
 
24
25
  class JobReport
25
26
  VERDICTS = { "passed" => :passed, "failed" => :failed, "lost" => :failed, "over_budget" => :over_budget,
26
- "running" => :undecided }.freeze
27
+ "running" => :undecided, "scheduled" => :undecided }.freeze
27
28
 
28
29
  def name = standing.name
29
30
  def cadence = standing.cadence
@@ -34,10 +35,12 @@ module FunCi
34
35
  # Whether the next commit starts it.
35
36
  def due? = standing.due_now?
36
37
 
37
- # The commit and branch its latest run tested, and when that started.
38
+ # The commit and branch its latest run tested, and when that started,
39
+ # or, while it waits its turn, when it is to.
38
40
  def sha = standing.run&.dig(:commit_hash)
39
41
  def branch = standing.run&.dig(:branch)
40
- def started_at = standing.run && Time.parse(standing.run[:started_at])
42
+ def started_at = standing.run && !starts_at ? Time.parse(standing.run[:started_at]) : nil
43
+ def starts_at = standing.starts_at
41
44
 
42
45
  # As `status` would exit for a stage in this state; a job that never ran, or was cancelled, has none.
43
46
  def verdict = VERDICTS.fetch(state, :unknown)
@@ -73,7 +76,8 @@ module FunCi
73
76
 
74
77
  def report(standing)
75
78
  run = standing.run
76
- JobReport.new(standing: standing, stage: run && stage(standing.name, run))
79
+ JobReport.new(standing: standing, stage: run && stage(standing.name, run),
80
+ starts_in: standing.starts_at && (standing.starts_at - @clock.now))
77
81
  end
78
82
 
79
83
  def stage(name, run)
@@ -3,6 +3,7 @@
3
3
  require_relative "stage_summary"
4
4
  require_relative "evidence_text"
5
5
  require_relative "span"
6
+ require_relative "starts_in"
6
7
 
7
8
  module FunCi
8
9
  module Agent
@@ -16,7 +17,7 @@ module FunCi
16
17
 
17
18
  def self.lines(report)
18
19
  summary = StageSummary.line(report.stage, seconds: Span.method(:words))
19
- [header(report), summary, *body(report.stage), *raw(report), *only_tail(report)]
20
+ [header(report), summary, *body(report), *raw(report), *only_tail(report)]
20
21
  end
21
22
 
22
23
  def self.header(report) = "fun-ci: job #{report.name} (#{report.cadence}) on #{report.branch} #{report.sha[0, 7]}"
@@ -25,7 +26,9 @@ module FunCi
25
26
  "fun-ci: job #{report.name} (#{report.cadence}) has not run yet; it runs on the next commit."
26
27
  end
27
28
 
28
- def self.body(stage)
29
+ def self.body(report)
30
+ stage = report.stage
31
+ return [waiting(report)] if report.starts_in
29
32
  return [PASSED] if stage.state == "passed"
30
33
  return [RUNNING] if stage.state == "running"
31
34
  return [CANCELLED] if stage.state == "cancelled"
@@ -33,6 +36,11 @@ module FunCi
33
36
  EvidenceText.lines(stage.evidence)
34
37
  end
35
38
 
39
+ def self.waiting(report)
40
+ "It waits its turn, and #{StartsIn.words(report.starts_in)}; " \
41
+ "fun-ci cancel --job #{report.name} cancels it."
42
+ end
43
+
36
44
  def self.raw(report)
37
45
  report.stage.raw_bytes ? ["", "The whole output: fun-ci why --job #{report.name} --raw"] : []
38
46
  end
@@ -45,7 +53,7 @@ module FunCi
45
53
  ["", "Only the output's last lines were kept; extractors under `evidence: jobs: #{report.name}:` " \
46
54
  "in .fun-ci/config keep more."]
47
55
  end
48
- private_class_method :body, :raw, :only_tail
56
+ private_class_method :body, :waiting, :raw, :only_tail
49
57
  end
50
58
  end
51
59
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "age"
4
4
  require_relative "due_in"
5
+ require_relative "starts_in"
5
6
  require_relative "span"
6
7
 
7
8
  module FunCi
@@ -14,7 +15,7 @@ module FunCi
14
15
  # command of each job that failed or ran over budget.
15
16
  module JobsText
16
17
  WORDS = { "passed" => "ok", "failed" => "FAIL", "lost" => "LOST", "over_budget" => "OVER", "running" => "...",
17
- "due" => "due", "cancelled" => "x" }.freeze
18
+ "due" => "due", "cancelled" => "x", "scheduled" => "wait" }.freeze
18
19
 
19
20
  # now: the time the ages and due times count from.
20
21
  def self.lines(reports, now)
@@ -34,13 +35,17 @@ module FunCi
34
35
  "#{head} #{seconds(report.stage)} #{tested(report, now)} #{due(report, now)}"
35
36
  end
36
37
 
37
- # `wip/foo 9e0b1d4 1h ago`
38
- def self.tested(report, now) = "#{report.branch} #{report.sha[0, 7]} #{Age.words(now - report.started_at)}"
38
+ # `wip/foo 9e0b1d4 1h ago`; a run waiting its turn has not started.
39
+ def self.tested(report, now)
40
+ commit = "#{report.branch} #{report.sha[0, 7]}"
41
+ report.started_at ? "#{commit} #{Age.words(now - report.started_at)}" : commit
42
+ end
39
43
 
40
44
  def self.seconds(stage) = (stage.seconds ? Span.words(stage.seconds) : "").rjust(6)
41
45
 
42
46
  def self.due(report, now)
43
47
  return "running" if report.state == "running"
48
+ return StartsIn.words(report.starts_in) if report.starts_in
44
49
 
45
50
  report.due_at ? "due in #{DueIn.words(report.due_at - now)}" : "runs on the next commit"
46
51
  end
@@ -6,10 +6,10 @@ require_relative "../setup/project_config"
6
6
 
7
7
  module FunCi
8
8
  module Agent
9
- # The project's pipeline, as `wait` starts and watches it: a run starts
10
- # the way the post-commit hook starts one, with `fun-ci trigger
11
- # --background` in a process of its own, rather than as a fork of the
12
- # waiting process and its open database connection.
9
+ # The project's pipeline, as `wait` starts and watches it and `cancel`
10
+ # stops a job of it: a run starts the way the post-commit hook starts one,
11
+ # with `fun-ci trigger --background` in a process of its own, rather than
12
+ # as a fork of the waiting process and its open database connection.
13
13
  class LivePipeline
14
14
  FUN_CI = File.expand_path("../../../exe/fun-ci", __dir__)
15
15
 
@@ -35,6 +35,9 @@ module FunCi
35
35
  canceller.record_dead(db)
36
36
  canceller.record_dead_jobs(db)
37
37
  end
38
+
39
+ # Stops a daily or weekly job's run and records it cancelled, as the console does.
40
+ def cancel_job(db, id) = Pipeline::RunCanceller.new.cancel_job(db, id)
38
41
  end
39
42
  end
40
43
  end
@@ -37,9 +37,13 @@ module FunCi
37
37
  def job_state(run)
38
38
  JobEvents::JobState.new(id: run[:id], job: run[:job], cadence: run[:cadence], sha: run[:commit_hash],
39
39
  branch: run[:branch], state: Jobs::State.of(run),
40
- seconds: Persistence::StageJob.elapsed_duration(run)&.round(1))
40
+ seconds: Persistence::StageJob.elapsed_duration(run)&.round(1),
41
+ starts_at: starts_at(run))
41
42
  end
42
43
 
44
+ # When a run waiting its turn starts, as an ISO time; nil for any other.
45
+ def starts_at(run) = run[:status] == "scheduled" ? Time.parse(run[:started_at]).utc.iso8601 : nil
46
+
43
47
  def state_of(run)
44
48
  superseded_by = run[:status] == "cancelled" ? @runs.superseded_by(run) : nil
45
49
  Events::RunState.new(id: run[:id], sha: run[:commit_hash], branch: run[:branch], status: run[:status],
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "due_in"
4
+
5
+ module FunCi
6
+ module Agent
7
+ # When a job waiting its turn (Jobs::Schedule) starts, as agents are
8
+ # told it: `starts in 6h`, or `starts now` once its time has come and its
9
+ # process has yet to start it.
10
+ module StartsIn
11
+ def self.words(seconds) = seconds.positive? ? "starts in #{DueIn.words(seconds)}" : "starts now"
12
+ end
13
+ end
14
+ end
@@ -19,7 +19,7 @@ module FunCi
19
19
  def self.unknown(sha) = { schema: SCHEMA, commit: { sha: sha }, verdict: "unknown" }
20
20
 
21
21
  def self.stage(stage)
22
- facts = { name: stage.name, state: stage.state, seconds: stage.seconds }
22
+ facts = { name: stage.name, state: stage.state, seconds: stage.seconds, budget: stage.budget }
23
23
  stage.failures.any? ? facts.merge(failures: stage.failures) : facts
24
24
  end
25
25
  private_class_method :stage
@@ -5,6 +5,8 @@ require_relative "verdict"
5
5
  require_relative "digest"
6
6
  require_relative "trunk_text"
7
7
  require_relative "job_report"
8
+ require_relative "starts_in"
9
+ require_relative "../pipeline/budgets"
8
10
 
9
11
  module FunCi
10
12
  module Agent
@@ -29,10 +31,13 @@ module FunCi
29
31
  # ` soak (weekly job) FAILED fun-ci why --job soak`; `where` names the
30
32
  # commit it tested when that is another.
31
33
  def self.job_line(job, where = "")
32
- said = " #{job.name} (#{job.cadence} job) #{WORDS.fetch(job.state, job.state)}#{where}"
34
+ said = " #{job.name} (#{job.cadence} job) #{WORDS.fetch(job.state, job.state)}#{starts(job)}#{where}"
33
35
  job.needs_you? ? "#{said} fun-ci why --job #{job.name}" : said
34
36
  end
35
37
 
38
+ # `, starts in 8m` for a job waiting its turn.
39
+ def self.starts(job) = job.starts_in ? ", #{StartsIn.words(job.starts_in)}" : ""
40
+
36
41
  # The trunk lines; nothing while the check is going, unless asked about
37
42
  # (right after a commit it would say nothing useful).
38
43
  def self.trunk(report, asked)
@@ -45,10 +50,17 @@ module FunCi
45
50
 
46
51
  def self.stage_line(stage, needed)
47
52
  seconds = stage.seconds ? format("%6.1fs", stage.seconds) : " " * 7
48
- note = needed.include?(stage.name) ? "" : " (not needed)"
53
+ note = (needed.include?(stage.name) ? "" : " (not needed)") + longer(stage)
49
54
  " #{stage.name.ljust(6)} #{WORDS.fetch(stage.state, stage.state).ljust(12)}#{seconds}#{note}".rstrip
50
55
  end
51
56
 
57
+ # Only a worktree just made gives a stage more than its usual budget
58
+ # (AT-1.14), which a run in the background says nowhere else.
59
+ def self.longer(stage)
60
+ usual = Pipeline::DEFAULT_BUDGETS[stage.name]
61
+ stage.budget && usual && stage.budget > usual ? " (new worktree, budget #{stage.budget}s)" : ""
62
+ end
63
+
52
64
  def self.footer(report)
53
65
  return ["fun-ci why #{report.sha[0, 7]} #{report.deciding}"] if report.deciding
54
66
  return ["fun-ci why #{report.sha[0, 7]} trunk"] if report.trunk&.state == "conflicts"
@@ -56,7 +68,7 @@ module FunCi
56
68
 
57
69
  ["Superseded by #{report.superseded_by[0, 7]}."]
58
70
  end
59
- private_class_method :stage_line, :footer, :job_lines, :job_line
71
+ private_class_method :stage_line, :longer, :footer, :job_lines, :job_line, :starts
60
72
  end
61
73
  end
62
74
  end
@@ -28,7 +28,8 @@ module FunCi
28
28
  return shown(check) if check
29
29
 
30
30
  started = run[:trunk_started_at]
31
- started && Trunk::Shown.unchecked(started: Time.parse(started), now: @clock.now)
31
+ started && Trunk::Shown.unchecked(started: Time.parse(started), now: @clock.now,
32
+ stages_running: !run[:trigger_pid].nil?)
32
33
  end
33
34
 
34
35
  # Checks the commit against where the trunk is now, without fetching, and keeps the check,
@@ -52,9 +52,10 @@ module FunCi
52
52
  ExitCode::FOR.fetch(report.verdict)
53
53
  end
54
54
 
55
+ # A waiter keeps its run from being superseded, unless it follows the branch to the newer commit.
55
56
  def poll(sha, options)
56
57
  @context.pipeline.watch(@context.db)
57
- reports.mark_waited(sha, @context.clock.now)
58
+ reports.mark_waited(sha, @context.clock.now) unless options.follow_branch
58
59
  report_for(sha, options) || start_after_grace(sha)
59
60
  end
60
61
 
data/lib/fun_ci/cli.rb CHANGED
@@ -17,7 +17,7 @@ module FunCi
17
17
  "prune" => :run_prune,
18
18
  "extract" => :run_extract
19
19
  }.freeze
20
- AGENT_COMMANDS = %w[status runs wait events why jobs].freeze
20
+ AGENT_COMMANDS = %w[status runs wait events why jobs cancel].freeze
21
21
 
22
22
  def self.default_db_dir = Persistence::StateDir.path(ENV)
23
23
 
@@ -32,6 +32,7 @@ module FunCi
32
32
  events Print this project's runs' events as JSON lines
33
33
  why Print everything kept about why a commit's stage failed, or its conflict with the trunk
34
34
  jobs List this project's daily and weekly jobs and how each stands
35
+ cancel Cancel a daily or weekly job's run: cancel --job NAME
35
36
  extract Try a stage's evidence extractors on a saved output
36
37
 
37
38
  Options:
@@ -47,7 +48,7 @@ module FunCi
47
48
  --follow-branch (wait) Move on to the newer commit that superseded the run
48
49
  --trunk (status, wait) Exit 6 when the run passed but conflicts with the trunk
49
50
  --raw (why) Print the stage's whole output as it was kept
50
- --job NAME (why) Explain the daily or weekly job's latest run instead of a commit's stage
51
+ --job NAME (why, cancel) The daily or weekly job to explain or cancel, instead of a commit
51
52
  -n N (runs) How many runs to list (default 10)
52
53
  --branch NAME (runs) Only runs on this branch
53
54
  --follow (events) Keep printing events as they happen, until stopped