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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +71 -0
- data/README.md +15 -8
- data/lib/fun_ci/agent/cancel_command.rb +52 -0
- data/lib/fun_ci/agent/commands.rb +3 -1
- data/lib/fun_ci/agent/job_events.rb +26 -7
- data/lib/fun_ci/agent/job_json.rb +10 -1
- data/lib/fun_ci/agent/job_report.rb +10 -6
- data/lib/fun_ci/agent/job_why_text.rb +11 -3
- data/lib/fun_ci/agent/jobs_text.rb +8 -3
- data/lib/fun_ci/agent/live_pipeline.rb +7 -4
- data/lib/fun_ci/agent/snapshots.rb +5 -1
- data/lib/fun_ci/agent/starts_in.rb +14 -0
- data/lib/fun_ci/agent/status_json.rb +1 -1
- data/lib/fun_ci/agent/status_text.rb +15 -3
- data/lib/fun_ci/agent/trunk_reading.rb +2 -1
- data/lib/fun_ci/agent/wait_command.rb +2 -1
- data/lib/fun_ci/cli.rb +1 -1
- data/lib/fun_ci/cli_help.rb +2 -1
- data/lib/fun_ci/console/job_message.rb +12 -6
- data/lib/fun_ci/console/job_order.rb +5 -4
- data/lib/fun_ci/console/key_handler.rb +3 -1
- data/lib/fun_ci/evidence/settings.rb +1 -1
- data/lib/fun_ci/jobs/due.rb +4 -3
- data/lib/fun_ci/jobs/due_jobs.rb +26 -2
- data/lib/fun_ci/jobs/job_fork.rb +19 -12
- data/lib/fun_ci/jobs/job_run.rb +26 -7
- data/lib/fun_ci/jobs/schedule.rb +25 -0
- data/lib/fun_ci/jobs/site.rb +10 -3
- data/lib/fun_ci/jobs/standings.rb +4 -0
- data/lib/fun_ci/jobs/state.rb +3 -2
- data/lib/fun_ci/jobs/wall_clock_wait.rb +29 -0
- data/lib/fun_ci/persistence/active_jobs.rb +13 -7
- data/lib/fun_ci/persistence/job_runs.rb +12 -0
- data/lib/fun_ci/persistence/pipeline_run.rb +2 -1
- data/lib/fun_ci/pipeline/budgets.rb +9 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +11 -8
- data/lib/fun_ci/pipeline/priorities.rb +23 -0
- data/lib/fun_ci/pipeline/process_runner.rb +7 -5
- data/lib/fun_ci/pipeline/progress_reporter.rb +4 -0
- data/lib/fun_ci/pipeline/slot.rb +11 -0
- data/lib/fun_ci/pipeline/slot_run.rb +19 -4
- data/lib/fun_ci/pipeline/trigger.rb +17 -6
- data/lib/fun_ci/pipeline/trigger_command.rb +6 -2
- data/lib/fun_ci/pipeline/trigger_params.rb +20 -9
- data/lib/fun_ci/pipeline/worktree_pool.rb +2 -2
- data/lib/fun_ci/pipeline/worktrees.rb +3 -1
- data/lib/fun_ci/setup/agent_instructions.rb +21 -5
- data/lib/fun_ci/setup/commands.rb +3 -1
- data/lib/fun_ci/setup/hook_script.rb +9 -4
- data/lib/fun_ci/setup/hook_writer.rb +13 -5
- data/lib/fun_ci/setup/installed_hooks.rb +45 -0
- data/lib/fun_ci/setup/installer.rb +3 -2
- data/lib/fun_ci/setup/lfs_hook.rb +50 -0
- data/lib/fun_ci/setup/project_config.rb +7 -1
- data/lib/fun_ci/setup/settings.rb +40 -11
- data/lib/fun_ci/setup/setup_checker.rb +14 -3
- data/lib/fun_ci/trunk/shown.rb +7 -4
- data/lib/fun_ci/version.rb +1 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b55c9059ad2ffd38aa87dd4dd9958631a14390bcae0e0e7577e78bd83d4c530f
|
|
4
|
+
data.tar.gz: 6c40537e0a4eb9425dc9eab279e22dec5fdfe0142bab07b49c47f290cdaceeb3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
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
|
|
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]
|
|
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
|
|
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
|
|
9
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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)
|
|
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
|
|
10
|
-
# the way the post-commit hook starts one,
|
|
11
|
-
# --background` in a process of its own, rather than
|
|
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
|
|
data/lib/fun_ci/cli_help.rb
CHANGED
|
@@ -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)
|
|
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
|