fun_ci 2.1.1-x86_64-linux → 2.3.0-x86_64-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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f37bc30d8cfa394baf3701449769df4652a65598a209c7c1ed870d9dc70e0221
4
- data.tar.gz: c66dd824f11d8c2b870e101271c6679dd36a15a8663a96dc9b2aa9b967ebd134
3
+ metadata.gz: 312bfad226919d4599a0bc5cacecef1c26f08dda6d522f2a181b0b8c0c39ef81
4
+ data.tar.gz: 1183694930f2aa2b655238523c2068e9278e51ecad3cf14be1c8065ad285d271
5
5
  SHA512:
6
- metadata.gz: 3f6030e8a6c675cca3112dd7f3c4da909c5d162090a40fefca9b85c3cf5d914a5b209516ec02fc5086cc6dffc744c092e13f3b8345af5826bee18341926e0e68
7
- data.tar.gz: 8c19530b1164151eda907bbeaf19abd44274ccdf3f7c3ce1d9eb222cc5bf227a2501df38b8cc360b5241598dfccf53aa5f3da9c70745787f497687f819a59a26
6
+ metadata.gz: 291d5610ff766c5c09903a53d0aac5eb7b7e376d4c36e39ac11cd7024a2081d763d8bcbe25312008c520bf250c124b12d0fce022904e86cced70671714fe14b4
7
+ data.tar.gz: 7b6524c10cbef0919c017e3ba0f784313c009c3c0bf6a0b19655f2f017ddcc148daaa1b402210e4ee579e528d7594b095aa7fd32ed56eaad455383e804ccbaa6
data/CHANGELOG.md CHANGED
@@ -7,6 +7,101 @@ 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
+
81
+ ## [2.2.0] - 2026-10-01
82
+
83
+ ### Added
84
+ - Daily and weekly jobs, for checks too long for a stage's budget. A script
85
+ in `.fun-ci/daily/` or `.fun-ci/weekly/` runs at most once a day or once a
86
+ week, started by your commits, in a worktree of its own, with 24 hours to
87
+ finish. The console shows them in a section of their own under the table,
88
+ and `c` cancels a running one; on a screen too short for both, the jobs
89
+ give way to the branches. `fun-ci check` lists them.
90
+ - The console's job section needs renderer 2.2.0, which the platform gems
91
+ bring. With an older renderer, as `cargo install` left it, the console
92
+ works but shows no jobs.
93
+ - `fun-ci prune` removes the jobs' worktrees too.
94
+ - Agents ask about jobs: `fun-ci jobs` lists them, `fun-ci why --job NAME`
95
+ prints what was kept about one's last run, `fun-ci status` names the jobs
96
+ that tested the commit and any failing on another, and `fun-ci events`
97
+ says when a job starts and finishes. The instructions `fun-ci init` writes
98
+ for agents mention them.
99
+
100
+ ### Fixed
101
+ - On a short screen, a project whose branches had all passed and were left
102
+ out (counted beside the keys) kept its label, with nothing under it. The
103
+ label now goes with them.
104
+
10
105
  ## [2.1.1] - 2026-09-30
11
106
 
12
107
  ### Fixed
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,10 +49,31 @@ 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
 
58
+ ## Daily and weekly jobs
59
+
60
+ Some checks take longer than the time between two commits: a mutation run, a soak test, a full audit of your dependencies. Put each in a script of its own under `.fun-ci/daily/` or `.fun-ci/weekly/`:
61
+
62
+ ```
63
+ .fun-ci/daily/mutation.sh
64
+ .fun-ci/weekly/soak.sh
65
+ ```
66
+
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.
68
+
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.
72
+
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.
74
+
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.
76
+
56
77
  ## Watching: the console
57
78
 
58
79
  ```bash
@@ -88,12 +109,16 @@ Five minutes after the latest run finished, the header goes quiet: a starry nigh
88
109
  |---|---|
89
110
  | ![A fire in a stone hearth, with a green lamp in the corner](docs/screenshots/quiet-fireplace.png) | ![A moonlit island with a palm, with a red lamp in the corner](docs/screenshots/quiet-island.png) |
90
111
 
112
+ The daily and weekly jobs have a section of their own under the last project: each job's name, how often it runs, its mark, what its last run did on which branch and commit, and when it is due again (`passed on main 3a1f9c2 · due in 14h`), or that it runs on your next commit. When none of them failed or runs, the section folds into one pale line.
113
+
114
+ ![The daily and weekly jobs under the table: soak failed a day ago on wip/foo, with a red stripe, mutation running for over an hour, with a blue one, deps-audit passed and due again in 14 hours, and lint-deep due on the next commit](docs/screenshots/jobs.png)
115
+
91
116
  A branch that conflicts with the trunk says so on the line under its name, `conflicts with main`, and the header plays two strands braiding into a knot; when a later commit on the branch merges cleanly, the knot unties. A project whose trunk fun-ci last fetched over an hour ago, or couldn't fetch, says so beside its name.
92
117
 
93
118
  Keys:
94
119
 
95
120
  - `j` and `k`, or the arrow keys, move the cursor down and up
96
- - `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
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
97
122
  - `q` quits
98
123
 
99
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.
@@ -105,10 +130,10 @@ The drawing is done by a separate program, `fun-ci-renderer`, written in Rust. T
105
130
  Every commit's output ends with the command that gets its verdict:
106
131
 
107
132
  ```
108
- 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
109
134
  ```
110
135
 
111
- 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.
112
137
 
113
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).
114
139
 
@@ -210,6 +235,8 @@ evidence:
210
235
  on: overrun
211
236
  ```
212
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
+
213
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.
214
241
 
215
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.
@@ -224,9 +251,12 @@ fun-ci install-hooks post-commit Install a single hook type
224
251
  fun-ci check Verify .fun-ci/ setup
225
252
  fun-ci console Watch the runs
226
253
  fun-ci status [commit] [--trunk] Where a commit's run stands; the verdict is the exit code
227
- 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
228
255
  fun-ci why [commit] [stage|trunk] Everything kept about why a stage failed, or the conflict with the trunk
229
256
  fun-ci runs This project's recent runs, newest first
257
+ fun-ci jobs This project's daily and weekly jobs and how each stands
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
230
260
  fun-ci events [--follow] The runs' events as JSON lines
231
261
  fun-ci extract <stage> --output <file> Try a stage's extractors on a saved output
232
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
@@ -4,6 +4,7 @@ require_relative "options"
4
4
  require_relative "reports"
5
5
  require_relative "exit_code"
6
6
  require_relative "trunk_verdict"
7
+ require_relative "job_report"
7
8
 
8
9
  module FunCi
9
10
  module Agent
@@ -31,6 +32,12 @@ module FunCi
31
32
 
32
33
  def reports = @reports ||= Reports.new(@context.db, @context.git, @context.clock, @context.trunk)
33
34
 
35
+ # After recording failed the jobs whose process died, which would otherwise read as running.
36
+ def job_reports
37
+ @context.pipeline.watch(@context.db)
38
+ JobReports.new(@context.db, @context.git.toplevel, @context.clock)
39
+ end
40
+
34
41
  def usage(message)
35
42
  @context.io.stderr.puts "fun-ci #{self.class::NAME}: #{message}"
36
43
  ExitCode::USAGE
@@ -10,6 +10,8 @@ require_relative "runs_command"
10
10
  require_relative "wait_command"
11
11
  require_relative "events_command"
12
12
  require_relative "why_command"
13
+ require_relative "jobs_command"
14
+ require_relative "cancel_command"
13
15
  require_relative "../trunk/local"
14
16
 
15
17
  module FunCi
@@ -18,7 +20,8 @@ module FunCi
18
20
  # database and the project's git.
19
21
  module Commands
20
22
  ALL = { "status" => StatusCommand, "runs" => RunsCommand, "wait" => WaitCommand,
21
- "events" => EventsCommand, "why" => WhyCommand }.freeze
23
+ "events" => EventsCommand, "why" => WhyCommand, "jobs" => JobsCommand,
24
+ "cancel" => CancelCommand }.freeze
22
25
 
23
26
  def self.run(name, args, context)
24
27
  ALL.fetch(name).new(context).run(args)
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Agent
5
+ # How long until something, rounded up, since it is still to come, in the
6
+ # largest unit it reaches once rounded: `45m`, `22h`, `7d`; as the console says it.
7
+ module DueIn
8
+ def self.words(seconds)
9
+ minutes = up(seconds, 60).clamp(1..)
10
+ hours = up(seconds, 3600)
11
+ return "#{minutes}m" if minutes < 60
12
+ return "#{hours}h" if hours < 24
13
+
14
+ "#{up(seconds, 86_400)}d"
15
+ end
16
+
17
+ def self.up(seconds, unit) = (seconds.to_r / unit).ceil
18
+ private_class_method :up
19
+ end
20
+ end
21
+ end
@@ -4,19 +4,23 @@ require "json"
4
4
  require_relative "command_support"
5
5
  require_relative "snapshots"
6
6
  require_relative "events"
7
+ require_relative "job_events"
7
8
  require_relative "waiting"
8
9
 
9
10
  module FunCi
10
11
  module Agent
11
12
  # `fun-ci events [--follow] [--only failures]` (acceptance-tests.md,
12
- # AT-9.16): one JSON line per event of the project's recent runs, then,
13
- # with --follow, each new one as it happens, until interrupted.
13
+ # AT-9.16, AT-13.23): one JSON line per event of the project's recent runs
14
+ # and job runs, then, with --follow, each new one as it happens, until
15
+ # interrupted.
14
16
  class EventsCommand
15
17
  include CommandSupport
16
18
 
17
19
  NAME = "events"
18
20
  SCHEMA = 1
19
21
  RECENT_RUNS = 10
22
+ # The first look: no runs, no job runs.
23
+ NOTHING_SEEN = [{}, {}].freeze
20
24
 
21
25
  def initialize(context)
22
26
  @context = context
@@ -24,7 +28,7 @@ module FunCi
24
28
 
25
29
  def run(args)
26
30
  options = Options.parse(args, takes: %i[follow only json])
27
- seen = emit({}, options.only)
31
+ seen = emit(NOTHING_SEEN, options.only)
28
32
  follow(seen, options.only) if options.follow
29
33
  0
30
34
  rescue Options::Invalid => e
@@ -43,13 +47,16 @@ module FunCi
43
47
  # Following ends when whoever follows stops it.
44
48
  end
45
49
 
46
- # Prints what happened since `before`, and answers the look it took.
47
- def emit(before, only)
48
- after = snapshots.take(limit: RECENT_RUNS)
49
- Events.between(before, after).select { |event| !only || Events.failure?(event) }.each { |event| print(event) }
50
+ # Prints what happened since `before` ([runs, job runs]), and answers the look it took.
51
+ def emit((runs, jobs), only)
52
+ after = [snapshots.take(limit: RECENT_RUNS), snapshots.jobs(limit: RECENT_RUNS)]
53
+ events = Events.between(runs, after.first) + JobEvents.between(jobs, after.last)
54
+ events.select { |event| !only || failure?(event) }.each { |event| print(event) }
50
55
  after
51
56
  end
52
57
 
58
+ def failure?(event) = Events.failure?(event) || JobEvents.failure?(event)
59
+
53
60
  def print(event)
54
61
  @context.io.stdout.puts JSON.generate({ schema: SCHEMA, **event })
55
62
  @context.io.stdout.flush
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../jobs/state"
4
+
5
+ module FunCi
6
+ module Agent
7
+ # What happened to a project's daily and weekly job runs between two
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.
11
+ module JobEvents
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)
15
+
16
+ # The states of a run that has not ended.
17
+ GOING = %w[running due scheduled].freeze
18
+
19
+ # before, after: { job run id => JobState }.
20
+ def self.between(before, after)
21
+ after.values.sort_by(&:id).flat_map { |run| of(run, before[run.id]) }
22
+ end
23
+
24
+ def self.failure?(event) = event[:event] == "job_finished" && Jobs::State.needs_you?(event[:state])
25
+
26
+ def self.of(run, was)
27
+ about = { job: run.job, cadence: run.cadence, commit: run.sha, branch: run.branch }
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"
46
+ end
47
+
48
+ def self.ended(run, was, about)
49
+ return [] if was&.state == run.state || GOING.include?(run.state)
50
+
51
+ [{ event: "job_finished", **about, state: run.state, seconds: run.seconds }]
52
+ end
53
+ private_class_method :of, :begun, :started?, :ended
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Agent
5
+ # One job as `jobs --json` and `status --json` give it. Fields once
6
+ # published keep their names.
7
+ module JobJson
8
+ def self.document(report)
9
+ stage = report.stage
10
+ { name: report.name, cadence: report.cadence, state: report.state,
11
+ commit: stage && { sha: report.sha, branch: report.branch }, seconds: stage&.seconds,
12
+ **times(report), due: report.due? }
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
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "report_stage"
5
+ require_relative "verdict"
6
+ require_relative "../jobs/standings"
7
+ require_relative "../persistence/raw_outputs"
8
+
9
+ module FunCi
10
+ module Agent
11
+ # A daily or weekly job as an agent is told it: where it stands
12
+ # (Jobs::Standing), and its latest run as a RunReport::Stage named after
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)
16
+
17
+ # The jobs `status` names for a commit: those whose latest run tested it,
18
+ # and those failing on another commit, which an agent would not hear of otherwise.
19
+ CommitJobs = Data.define(:on_commit, :failing)
20
+
21
+ class CommitJobs
22
+ NONE = new(on_commit: [], failing: [])
23
+ end
24
+
25
+ class JobReport
26
+ VERDICTS = { "passed" => :passed, "failed" => :failed, "lost" => :failed, "over_budget" => :over_budget,
27
+ "running" => :undecided, "scheduled" => :undecided }.freeze
28
+
29
+ def name = standing.name
30
+ def cadence = standing.cadence
31
+ def state = standing.state
32
+ def needs_you? = standing.needs_you?
33
+ def due_at = standing.due_at
34
+
35
+ # Whether the next commit starts it.
36
+ def due? = standing.due_now?
37
+
38
+ # The commit and branch its latest run tested, and when that started,
39
+ # or, while it waits its turn, when it is to.
40
+ def sha = standing.run&.dig(:commit_hash)
41
+ def branch = standing.run&.dig(:branch)
42
+ def started_at = standing.run && !starts_at ? Time.parse(standing.run[:started_at]) : nil
43
+ def starts_at = standing.starts_at
44
+
45
+ # As `status` would exit for a stage in this state; a job that never ran, or was cancelled, has none.
46
+ def verdict = VERDICTS.fetch(state, :unknown)
47
+ end
48
+
49
+ # The daily and weekly jobs of a project, as JobReports.
50
+ class JobReports
51
+ def initialize(db, project, clock)
52
+ @db = db
53
+ @project = project
54
+ @clock = clock
55
+ end
56
+
57
+ # By name.
58
+ def all = Jobs::Standings.new(@db, @project, now: @clock.now).all.map { |standing| report(standing) }
59
+
60
+ # The job named, or nil when the project has no such job.
61
+ def named(name) = all.find { |report| report.name == name }
62
+
63
+ # The jobs whose latest run tested `sha`, and those failing on another commit.
64
+ def of_commit(sha)
65
+ reports = all
66
+ CommitJobs.new(on_commit: reports.select { |report| report.sha == sha },
67
+ failing: reports.select { |report| report.needs_you? && report.sha != sha })
68
+ end
69
+
70
+ # What the job's latest run kept of its raw output, or nil.
71
+ def raw_output(report) = report.stage && raw.read(report.stage.id)
72
+
73
+ private
74
+
75
+ def raw = Persistence::RawOutputs.for_jobs(@db.filename("main"))
76
+
77
+ def report(standing)
78
+ run = standing.run
79
+ JobReport.new(standing: standing, stage: run && stage(standing.name, run),
80
+ starts_in: standing.starts_at && (standing.starts_at - @clock.now))
81
+ end
82
+
83
+ def stage(name, run)
84
+ RunReport::Stage.from_row(name,
85
+ run.merge(budget: run[:budget] || Jobs::Job::BUDGET, raw_bytes: raw.bytes(run[:id])))
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "job_report"
5
+ require_relative "job_why_text"
6
+ require_relative "job_why_json"
7
+ require_relative "exit_code"
8
+ require_relative "options"
9
+
10
+ module FunCi
11
+ module Agent
12
+ # `fun-ci why --job NAME [--json] [--raw]` (acceptance-tests.md, AT-13.21):
13
+ # everything kept about the job's latest run, exiting as `why` does for a
14
+ # stage in the same state; a job that never ran has no verdict.
15
+ class JobWhy
16
+ NO_JOBS = "this project has no daily or weekly jobs; put their scripts in .fun-ci/daily/ or .fun-ci/weekly/"
17
+
18
+ def initialize(context, jobs)
19
+ @context = context
20
+ @jobs = jobs
21
+ end
22
+
23
+ # Raises Options::Invalid for a name that is no job of the project.
24
+ def answer(options)
25
+ report = @jobs.named(options.job) || raise(Options::Invalid, no_job(options.job))
26
+ return never_ran(report, options.json) unless report.stage
27
+
28
+ options.raw ? raw(report) : say(report, options.json)
29
+ ExitCode::FOR.fetch(report.verdict)
30
+ end
31
+
32
+ private
33
+
34
+ def say(report, json) = json ? print_json(report) : print_lines(JobWhyText.lines(report))
35
+
36
+ def never_ran(report, json)
37
+ json ? print_json(report) : print_lines([JobWhyText.never_ran(report)])
38
+ ExitCode::FOR.fetch(:unknown)
39
+ end
40
+
41
+ def raw(report)
42
+ text = @jobs.raw_output(report)
43
+ return @context.io.stdout.write(text) if text
44
+
45
+ @context.io.stderr.puts "fun-ci why: no raw output is kept for job #{report.name}"
46
+ end
47
+
48
+ def no_job(name)
49
+ names = @jobs.all.map(&:name)
50
+ return NO_JOBS if names.empty?
51
+
52
+ "no job '#{name}' in this project: its jobs are #{names.join(", ")}"
53
+ end
54
+
55
+ def print_json(report) = @context.io.stdout.puts(JSON.generate(JobWhyJson.document(report)))
56
+ def print_lines(lines) = lines.each { |line| @context.io.stdout.puts line }
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Agent
5
+ # `fun-ci why --job NAME --json` (acceptance-tests.md, AT-13.21): the job,
6
+ # how its latest run ended, its evidence, or else why there is none, as
7
+ # `why --json` gives them for a stage. Fields once published keep their
8
+ # names; a change bumps SCHEMA.
9
+ module JobWhyJson
10
+ SCHEMA = 1
11
+
12
+ def self.document(report)
13
+ stage = report.stage
14
+ { schema: SCHEMA, name: report.name, cadence: report.cadence, state: report.state,
15
+ commit: stage && { sha: report.sha, branch: report.branch }, **ending(stage), **evidence(report),
16
+ raw_output: { kept: !stage&.raw_bytes.nil?, bytes: stage&.raw_bytes || 0 } }
17
+ end
18
+
19
+ # The evidence of a run that failed or ran over budget, or else why there is none.
20
+ def self.evidence(report)
21
+ failed = report.needs_you?
22
+ { evidence: failed ? report.stage.evidence.to_h : nil, no_evidence: failed ? nil : report.state }
23
+ end
24
+
25
+ def self.ending(stage)
26
+ { exit_status: stage&.exit_status, signal: stage&.signal, seconds: stage&.seconds, budget: stage&.budget }
27
+ end
28
+ private_class_method :ending, :evidence
29
+ end
30
+ end
31
+ end