agent-lock 0.2.1
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 +7 -0
- data/.claude/CLAUDE.md +1 -0
- data/.envrc +2 -0
- data/.rubocop_todo.yml +90 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +54 -0
- data/CHANGELOG.md +48 -0
- data/LICENSE.txt +21 -0
- data/README.md +374 -0
- data/Rakefile +12 -0
- data/exe/agent-lock +8 -0
- data/exe/alock +9 -0
- data/justfile +127 -0
- data/lib/agent/lock/cli/commands/acquire.rb +87 -0
- data/lib/agent/lock/cli/commands/base.rb +148 -0
- data/lib/agent/lock/cli/commands/break.rb +36 -0
- data/lib/agent/lock/cli/commands/check.rb +56 -0
- data/lib/agent/lock/cli/commands/completion.rb +50 -0
- data/lib/agent/lock/cli/commands/list.rb +70 -0
- data/lib/agent/lock/cli/commands/mine.rb +35 -0
- data/lib/agent/lock/cli/commands/note.rb +37 -0
- data/lib/agent/lock/cli/commands/release.rb +33 -0
- data/lib/agent/lock/cli/commands/release_all.rb +24 -0
- data/lib/agent/lock/cli/commands/resume.rb +36 -0
- data/lib/agent/lock/cli/commands/skill.rb +75 -0
- data/lib/agent/lock/cli/commands/version.rb +20 -0
- data/lib/agent/lock/cli/commands/whoami.rb +83 -0
- data/lib/agent/lock/cli.rb +80 -0
- data/lib/agent/lock/error.rb +10 -0
- data/lib/agent/lock/freeze.rb +109 -0
- data/lib/agent/lock/identity.rb +166 -0
- data/lib/agent/lock/launcher.rb +126 -0
- data/lib/agent/lock/manager.rb +313 -0
- data/lib/agent/lock/process_info.rb +60 -0
- data/lib/agent/lock/record.rb +216 -0
- data/lib/agent/lock/scope.rb +173 -0
- data/lib/agent/lock/skill.rb +104 -0
- data/lib/agent/lock/store/file_system_store.rb +161 -0
- data/lib/agent/lock/store/redis_store.rb +225 -0
- data/lib/agent/lock/store.rb +104 -0
- data/lib/agent/lock/tree.rb +126 -0
- data/lib/agent/lock/version.rb +7 -0
- data/lib/agent/lock.rb +30 -0
- data/sig/agent/lock.rbs +6 -0
- data/skills/agent-lock/SKILL.md +59 -0
- metadata +150 -0
data/justfile
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Tell 'just' to run bash, source our setup script, then execute the recipe
|
|
2
|
+
set shell := ["bash", "-c"]
|
|
3
|
+
|
|
4
|
+
version := `grep VERSION lib/agent/lock/version.rb | awk '{print $3}' | tr -d '"' | tr -d '\n'`
|
|
5
|
+
repo := 'git@github.com:kigster/agent-lock.git'
|
|
6
|
+
|
|
7
|
+
rbenv := 'eval "$(rbenv init - bash 2>/dev/null || true)"; bundle exec '
|
|
8
|
+
|
|
9
|
+
# 1Password secret reference for the RubyGems TOTP, and the full path to `op`
|
|
10
|
+
# because a recipe does not inherit an interactive shell's PATH.
|
|
11
|
+
# The account is NOT named here: it is a private 1Password address and this
|
|
12
|
+
# file is public. `op` reads it from OP_ACCOUNT, exported by the ecosystem
|
|
13
|
+
# root's .envrc, which lives outside every repo. Two accounts are
|
|
14
|
+
# registered and only one holds the open-source-repos vault, so without
|
|
15
|
+
# OP_ACCOUNT the read can resolve against the wrong one, return nothing,
|
|
16
|
+
# and publish silently falls back to prompting mid-release.
|
|
17
|
+
op := '/opt/homebrew/bin/op'
|
|
18
|
+
otp_ref := 'op://open-source-repos/ruby-gems/one-time password?attribute=otp'
|
|
19
|
+
|
|
20
|
+
gem_name := 'agent-lock'
|
|
21
|
+
gem_file := 'pkg/' + gem_name + '-' + version + '.gem'
|
|
22
|
+
gem_url := 'https://rubygems.org/gems/' + gem_name
|
|
23
|
+
|
|
24
|
+
[no-exit-message]
|
|
25
|
+
recipes:
|
|
26
|
+
just --choose
|
|
27
|
+
|
|
28
|
+
# Sync all dependencies
|
|
29
|
+
install:
|
|
30
|
+
bin/setup
|
|
31
|
+
|
|
32
|
+
build: install
|
|
33
|
+
|
|
34
|
+
# standardrb
|
|
35
|
+
lint:
|
|
36
|
+
{{ rbenv }} standardrb
|
|
37
|
+
|
|
38
|
+
# Fix style with standardrb, then format the markdown
|
|
39
|
+
format:
|
|
40
|
+
bundle exec rubocop -a
|
|
41
|
+
bundle exec rubocop --auto-gen-config
|
|
42
|
+
/usr/bin/find . -name '*.md' -exec mdformat --wrap no {} \; -print
|
|
43
|
+
|
|
44
|
+
update-workflow:
|
|
45
|
+
bundle exec tilda docs -o docs/WORKFLOW.md
|
|
46
|
+
|
|
47
|
+
# Run all the tests
|
|
48
|
+
test *args:
|
|
49
|
+
export ENVIRONMENT=test; {{ rbenv }} rspec {{ args }}
|
|
50
|
+
|
|
51
|
+
# Run tests with coverage
|
|
52
|
+
test-coverage *args:
|
|
53
|
+
export ENVIRONMENT=test; export COVERAGE=true; {{ rbenv }} rspec {{ args }}
|
|
54
|
+
|
|
55
|
+
ci: lint test-coverage
|
|
56
|
+
|
|
57
|
+
alias check-all := ci
|
|
58
|
+
|
|
59
|
+
clean:
|
|
60
|
+
#!/usr/bin/env bash
|
|
61
|
+
@find . -name .DS_Store -delete -print || true
|
|
62
|
+
@rm -rf tmp/*
|
|
63
|
+
|
|
64
|
+
# Run all lefthook pre-commit hooks
|
|
65
|
+
lefthook:
|
|
66
|
+
{{ rbenv }} lefthook run pre-commit --all-files
|
|
67
|
+
|
|
68
|
+
# Print current gem version
|
|
69
|
+
version:
|
|
70
|
+
@echo "{{ version }}"
|
|
71
|
+
|
|
72
|
+
# Clobber
|
|
73
|
+
clobber:
|
|
74
|
+
{{ rbenv }} rake clobber
|
|
75
|
+
|
|
76
|
+
# Generate documentation
|
|
77
|
+
doc:
|
|
78
|
+
#!/usr/bin/env bash
|
|
79
|
+
{{ rbenv }} rake doc
|
|
80
|
+
|
|
81
|
+
# `gem push` rather than `rake release`: release also guards the tree, tags and
|
|
82
|
+
# pushes git — which `just release` does deliberately and separately — and it
|
|
83
|
+
# gives no way to pass a 2FA code, so it always stopped to prompt.
|
|
84
|
+
#
|
|
85
|
+
# The code comes from 1Password unless one is passed in:
|
|
86
|
+
#
|
|
87
|
+
# just publish # read the code from 1Password
|
|
88
|
+
# just publish 123456 # use this code
|
|
89
|
+
#
|
|
90
|
+
# `just publish-all` in agent-lock-tools passes one, because a TOTP is single-use:
|
|
91
|
+
# four gems reading the same 30-second window would have the second push
|
|
92
|
+
# rejected as a replay.
|
|
93
|
+
#
|
|
94
|
+
# Build the .gem and push it to RubyGems, non-interactively
|
|
95
|
+
publish otp="": build
|
|
96
|
+
#!/usr/bin/env bash
|
|
97
|
+
set -euo pipefail
|
|
98
|
+
eval "$(rbenv init - bash 2>/dev/null || true)"
|
|
99
|
+
|
|
100
|
+
mkdir -p pkg
|
|
101
|
+
gem build {{ gem_name }}.gemspec --output "{{ gem_file }}"
|
|
102
|
+
|
|
103
|
+
# `|| true` is load-bearing: under `set -e` a failed `op read` — not signed
|
|
104
|
+
# in to 1Password, item renamed, op not installed — would abort the recipe
|
|
105
|
+
# before the prompting fallback below could run.
|
|
106
|
+
otp="{{ otp }}"
|
|
107
|
+
[[ -n "${otp}" ]] || otp=$({{ op }} read "{{ otp_ref }}" 2>/dev/null || true)
|
|
108
|
+
|
|
109
|
+
if [[ -n "${otp}" ]]; then
|
|
110
|
+
{{ rbenv }} gem push "{{ gem_file }}" --otp "${otp}"
|
|
111
|
+
else
|
|
112
|
+
echo "rubygems: no OTP available — gem push will prompt if 2FA is required."
|
|
113
|
+
{{ rbenv }} gem push "{{ gem_file }}" --no-document
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
# Only reachable when the push succeeded: `set -e` aborts the recipe on a
|
|
117
|
+
# non-zero `gem push`, so the page never opens for a release that failed.
|
|
118
|
+
echo "published {{ gem_name }} {{ version }} → {{ gem_url }}"
|
|
119
|
+
open "{{ gem_url }}" 2>/dev/null || xdg-open "{{ gem_url }}" 2>/dev/null || true
|
|
120
|
+
|
|
121
|
+
# Tag v{{ version }}, publish the GH release, & refresh the Homebrew tap.
|
|
122
|
+
release:
|
|
123
|
+
git fetch --tags
|
|
124
|
+
git tag -f "v{{ version }}"
|
|
125
|
+
git push -f --tags
|
|
126
|
+
gh release delete -y "v{{ version }}" --repo {{ repo }} 2>/dev/null || true
|
|
127
|
+
gh release create "v{{ version }}" --generate-notes --repo {{ repo }}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module Lock
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# Claim a scope, or find out who has it.
|
|
10
|
+
class Acquire < Base
|
|
11
|
+
desc "Claim a path or glob; exits 1 if somebody else holds it"
|
|
12
|
+
|
|
13
|
+
argument :scope, required: true, desc: "A path, a directory, or a glob such as 'workflow/**'"
|
|
14
|
+
argument :intent, required: false, desc: "What you are about to do, for whoever reads the lock"
|
|
15
|
+
|
|
16
|
+
option :enforce, type: :boolean, default: false,
|
|
17
|
+
desc: "Also make the matched files unwritable (macOS)"
|
|
18
|
+
option :force, type: :boolean, default: false,
|
|
19
|
+
desc: "Allow --enforce on a very wide scope"
|
|
20
|
+
|
|
21
|
+
example [
|
|
22
|
+
"workflow/** 'rewriting the installer'",
|
|
23
|
+
"lib/agent/lock/cli.rb 'adding the json flag'",
|
|
24
|
+
"'**' 'a migration that touches everything'"
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# @param scope [String]
|
|
28
|
+
# @param intent [String, nil]
|
|
29
|
+
# @param options [Hash]
|
|
30
|
+
# @return [Manager::Result]
|
|
31
|
+
def call(scope:, intent: nil, **options)
|
|
32
|
+
manager = manager(options[:dir])
|
|
33
|
+
result = manager.acquire(
|
|
34
|
+
scope, intent: intent || "unspecified",
|
|
35
|
+
enforce: options[:enforce], force: options[:force]
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
report(result, manager.stale_minutes)
|
|
39
|
+
finish(result)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# @param result [Manager::Result]
|
|
45
|
+
# @param stale_minutes [Integer]
|
|
46
|
+
def report(result, stale_minutes)
|
|
47
|
+
case result.status
|
|
48
|
+
when :acquired then report_acquired(result)
|
|
49
|
+
when :already_mine then say("ALREADY YOURS #{result.record.scope}")
|
|
50
|
+
when :held
|
|
51
|
+
warn_("REFUSED, do not write here")
|
|
52
|
+
report_held(result.records, stale_minutes: stale_minutes)
|
|
53
|
+
when :interrupted then report_interrupted(result.record)
|
|
54
|
+
when :parent_scope then report_parent_scope(result.record)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# A refusal no amount of waiting fixes, so it says what will work
|
|
59
|
+
# instead. Locks are keyed by scope, which makes the parent's record
|
|
60
|
+
# the very one this child would have to write.
|
|
61
|
+
#
|
|
62
|
+
# @param record [Record] the parent's lock on this exact scope
|
|
63
|
+
def report_parent_scope(record)
|
|
64
|
+
warn_("REFUSED: #{record.scope} is your parent's (#{record.agent_id}) whole claim; " \
|
|
65
|
+
"claim a narrower scope inside it")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def report_acquired(result)
|
|
69
|
+
record = result.record
|
|
70
|
+
say("ACQUIRED #{record.scope} (holder: #{record.agent_id})")
|
|
71
|
+
say("FROZEN #{record.frozen_paths.size} file(s)") if record.frozen_paths.any?
|
|
72
|
+
warn_(result.message) if result.message
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# An orphan is somebody's unfinished work, so the two ways out of it
|
|
76
|
+
# are spelled out rather than left to be looked up.
|
|
77
|
+
#
|
|
78
|
+
# @param record [Record] the orphaned lock on this exact scope
|
|
79
|
+
def report_interrupted(record)
|
|
80
|
+
warn_("INTERRUPTED WORK on #{record.scope}, left by #{record.agent_id}")
|
|
81
|
+
advise_interrupted(record)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli"
|
|
4
|
+
require_relative "../../manager"
|
|
5
|
+
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Agent
|
|
9
|
+
module Lock
|
|
10
|
+
module CLI
|
|
11
|
+
module Commands
|
|
12
|
+
# What every command shares: the launcher it prints through, the
|
|
13
|
+
# manager it delegates to, and the wording of a held lock.
|
|
14
|
+
#
|
|
15
|
+
# Nothing here calls `puts` or `exit`. Output goes to the launcher's
|
|
16
|
+
# streams and the exit code is handed back to it, which is what lets
|
|
17
|
+
# the whole CLI run inside the test process.
|
|
18
|
+
class Base < Dry::CLI::Command
|
|
19
|
+
# Inherited by every command, so `--dir` works everywhere without
|
|
20
|
+
# each one restating it.
|
|
21
|
+
def self.inherited(subclass)
|
|
22
|
+
super
|
|
23
|
+
subclass.option :dir, type: :string, default: ".",
|
|
24
|
+
desc: "Work as if run from this directory"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_reader :launcher
|
|
28
|
+
|
|
29
|
+
# @param launcher [Launcher]
|
|
30
|
+
def initialize(launcher = nil)
|
|
31
|
+
super()
|
|
32
|
+
@launcher = launcher
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def stdout = launcher.stdout
|
|
38
|
+
|
|
39
|
+
def stderr = launcher.stderr
|
|
40
|
+
|
|
41
|
+
# What to call this CLI in a hint, so the command it suggests is
|
|
42
|
+
# one the user can paste back: `alock` for somebody who typed `alock`.
|
|
43
|
+
#
|
|
44
|
+
# @return [String]
|
|
45
|
+
def program = launcher.program
|
|
46
|
+
|
|
47
|
+
# @param dir [String]
|
|
48
|
+
# @return [Manager]
|
|
49
|
+
def manager(dir) = Manager.new(tree: Tree.for(dir || "."))
|
|
50
|
+
|
|
51
|
+
# @param text [String]
|
|
52
|
+
def say(text) = stdout.puts(text)
|
|
53
|
+
|
|
54
|
+
# STDOUT is flushed first. An agent harness reads both streams down
|
|
55
|
+
# one pipe, where STDOUT is block-buffered and STDERR is not, and
|
|
56
|
+
# without the flush every hint arrives ahead of the record it is
|
|
57
|
+
# about.
|
|
58
|
+
#
|
|
59
|
+
# @param text [String]
|
|
60
|
+
def warn_(text)
|
|
61
|
+
stdout.flush
|
|
62
|
+
stderr.puts(text)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param result [Manager::Result]
|
|
66
|
+
def finish(result)
|
|
67
|
+
launcher.exit_code = result.code
|
|
68
|
+
result
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The one message worth getting right: an agent that hits a lock
|
|
72
|
+
# should learn who has it, since when, and what they are doing, so it
|
|
73
|
+
# can decide whether to wait or to work somewhere else. A stale
|
|
74
|
+
# holder changes that decision, from waiting to asking somebody, so
|
|
75
|
+
# it is said outright rather than left to be worked out from a date.
|
|
76
|
+
#
|
|
77
|
+
# @param records [Array<Record>]
|
|
78
|
+
# @param stale_minutes [Integer] Manager#stale_minutes
|
|
79
|
+
def report_held(records, stale_minutes:)
|
|
80
|
+
records.each do |record|
|
|
81
|
+
warn_(["HELD #{record.scope} by #{record.agent_id} since #{record.created_at}",
|
|
82
|
+
tag_for(record, stale_minutes)].compact.join(" "))
|
|
83
|
+
warn_(" intent: #{record.intent}") unless record.intent.empty?
|
|
84
|
+
warn_(" frozen: #{record.frozen_paths.size} file(s)") if record.frozen_paths.any?
|
|
85
|
+
warn_stale(record, stale_minutes) if record.stale?(stale_minutes)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# One line per record, tagged, so that a line lifted out by `grep`
|
|
90
|
+
# still says whether it is a claim in good standing.
|
|
91
|
+
#
|
|
92
|
+
# @param records [Array<Record>]
|
|
93
|
+
# @param json [Boolean]
|
|
94
|
+
# @param stale_minutes [Integer] Manager#stale_minutes
|
|
95
|
+
def report_records(records, json:, stale_minutes:)
|
|
96
|
+
return say(JSON.pretty_generate(records.map { |record| as_json(record, stale_minutes) })) if json
|
|
97
|
+
|
|
98
|
+
records.each do |record|
|
|
99
|
+
say([record.scope, record.agent_id, record.created_at, tag_for(record, stale_minutes)].compact.join("\t"))
|
|
100
|
+
say(" #{record.intent}") unless record.intent.empty?
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The two ways out of an interrupted lock, spelled out rather than
|
|
105
|
+
# left to be looked up, since each throws away something different.
|
|
106
|
+
#
|
|
107
|
+
# @param record [Record] an orphaned lock
|
|
108
|
+
def advise_interrupted(record)
|
|
109
|
+
warn_(" #{program} resume #{record.scope} # take it back, notes and all")
|
|
110
|
+
warn_(" #{program} break #{record.scope} # throw it away and start over")
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# @param record [Record]
|
|
114
|
+
# @param stale_minutes [Integer]
|
|
115
|
+
# @return [String, nil] INTERRUPTED, STALE, or nil for a claim in good standing
|
|
116
|
+
def tag_for(record, stale_minutes)
|
|
117
|
+
return "INTERRUPTED" if record.orphaned?
|
|
118
|
+
|
|
119
|
+
"STALE" if record.stale?(stale_minutes)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A stale holder is not a dead one: a holder that died on this machine
|
|
123
|
+
# is reaped on its own, so this one is most likely still running and
|
|
124
|
+
# merely quiet. Breaking it is a decision somebody announces, never a
|
|
125
|
+
# default.
|
|
126
|
+
#
|
|
127
|
+
# @param record [Record]
|
|
128
|
+
# @param stale_minutes [Integer]
|
|
129
|
+
def warn_stale(record, stale_minutes)
|
|
130
|
+
warn_(" stale: untouched for over #{stale_minutes} minutes; ask its holder, " \
|
|
131
|
+
"or announce it before `#{program} break #{record.scope}`")
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# `stale` is computed, not stored, so it is added here; `status`
|
|
135
|
+
# already says whether a record is interrupted.
|
|
136
|
+
#
|
|
137
|
+
# @param record [Record]
|
|
138
|
+
# @param stale_minutes [Integer]
|
|
139
|
+
# @return [Hash]
|
|
140
|
+
def as_json(record, stale_minutes)
|
|
141
|
+
Record::FIELDS.to_h { |field| [field, record.public_send(field)] }
|
|
142
|
+
.merge(intent: record.intent, stale: record.stale?(stale_minutes))
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module Lock
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# Take a lock away from whoever holds it.
|
|
10
|
+
#
|
|
11
|
+
# Deliberately blunt and deliberately loud: an expired lock is reaped
|
|
12
|
+
# on its own, so reaching for this means overruling a holder that still
|
|
13
|
+
# looks alive. Whoever runs it owns the consequence.
|
|
14
|
+
class Break < Base
|
|
15
|
+
desc "Steal a lock; announce it first"
|
|
16
|
+
|
|
17
|
+
argument :scope, required: true, desc: "The path or glob to take"
|
|
18
|
+
|
|
19
|
+
def call(scope:, **options)
|
|
20
|
+
result = manager(options[:dir]).break_lock(scope)
|
|
21
|
+
|
|
22
|
+
if result.status == :not_found
|
|
23
|
+
say("NOT LOCKED #{scope}")
|
|
24
|
+
else
|
|
25
|
+
warn_("BREAKING #{result.record.scope}, held by #{result.record.agent_id}")
|
|
26
|
+
warn_(" intent: #{result.record.intent}") unless result.record.intent.empty?
|
|
27
|
+
say("Broken. You are responsible for having announced this first.")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
finish(result)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module Lock
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# Ask before writing. Exits 1 when anything overlapping is held, so a
|
|
10
|
+
# shell script can gate on it without parsing anything.
|
|
11
|
+
class Check < Base
|
|
12
|
+
desc "Say who holds a path; exits 1 if it is held"
|
|
13
|
+
|
|
14
|
+
argument :scope, required: true, desc: "A path, a directory, or a glob"
|
|
15
|
+
|
|
16
|
+
option :json, type: :boolean, default: false, desc: "Machine-readable output"
|
|
17
|
+
|
|
18
|
+
example ["workflow/**", "lib/agent/lock/cli.rb --json"]
|
|
19
|
+
|
|
20
|
+
# @param scope [String]
|
|
21
|
+
# @param options [Hash]
|
|
22
|
+
# @return [Manager::Result]
|
|
23
|
+
def call(scope:, **options)
|
|
24
|
+
manager = manager(options[:dir])
|
|
25
|
+
result = manager.check(scope)
|
|
26
|
+
report(result, scope, json: options[:json], stale_minutes: manager.stale_minutes)
|
|
27
|
+
finish(result)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# @param result [Manager::Result]
|
|
33
|
+
# @param scope [String] as the user typed it
|
|
34
|
+
# @param json [Boolean]
|
|
35
|
+
# @param stale_minutes [Integer]
|
|
36
|
+
def report(result, scope, json:, stale_minutes:)
|
|
37
|
+
case result.status
|
|
38
|
+
when :free then say(json ? "[]" : "FREE #{scope}")
|
|
39
|
+
when :mine
|
|
40
|
+
# Held, but by you: safe to write, and worth saying which of your
|
|
41
|
+
# own locks covers it rather than a bare "free".
|
|
42
|
+
report_records(result.records, json: json, stale_minutes: stale_minutes)
|
|
43
|
+
say("YOURS #{result.record.scope}") unless json
|
|
44
|
+
when :held
|
|
45
|
+
if json
|
|
46
|
+
report_records(result.records, json: true, stale_minutes: stale_minutes)
|
|
47
|
+
else
|
|
48
|
+
report_held(result.records, stale_minutes: stale_minutes)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/cli/autocomplete/command"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module Lock
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# `alock completion bash|zsh`, from dry-cli-autocomplete, with the two
|
|
10
|
+
# things that gem's command cannot do for us restated here.
|
|
11
|
+
#
|
|
12
|
+
# It writes to `$stdout` directly, which the in-process launcher can
|
|
13
|
+
# neither capture nor redirect, and it binds a registry by way of
|
|
14
|
+
# `Class.new`, which is exactly where dry-cli clears an inherited
|
|
15
|
+
# `desc` and `example`. Both belong upstream; until then, here.
|
|
16
|
+
class Completion < Dry::CLI::Autocomplete::Command
|
|
17
|
+
desc "Print a shell completion script for bash or zsh"
|
|
18
|
+
|
|
19
|
+
# Spelled out rather than taken from $PROGRAM_NAME, which under a
|
|
20
|
+
# test runner names the runner, and which upstream reads at load
|
|
21
|
+
# time anyway.
|
|
22
|
+
example [
|
|
23
|
+
"bash > \"$(brew --prefix)/etc/bash_completion.d/alock\"",
|
|
24
|
+
"zsh > \"${fpath[1]}/_alo\""
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# Binding a registry subclasses this command, and dry-cli's
|
|
28
|
+
# `inherited` hook starts every subclass with no description and no
|
|
29
|
+
# examples. Arguments and options survive it; those two do not, so
|
|
30
|
+
# they are copied across by hand.
|
|
31
|
+
#
|
|
32
|
+
# @param registry [Dry::CLI::Registry]
|
|
33
|
+
# @return [Class]
|
|
34
|
+
def self.[](registry, program_name: nil)
|
|
35
|
+
super.tap do |bound|
|
|
36
|
+
bound.desc(description)
|
|
37
|
+
bound.example(*examples)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# dry-cli hands every command the stream it should be writing to.
|
|
44
|
+
# The command upstream ignores it; this one does not.
|
|
45
|
+
def out = @out || $stdout
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module Lock
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# Everything in this tree's store, worktree siblings included, since
|
|
10
|
+
# they share one `.git` and therefore one set of locks.
|
|
11
|
+
#
|
|
12
|
+
# Live claims and interrupted work are listed apart. An orphan blocks
|
|
13
|
+
# nobody, so counting it as held told a reader the tree was busier
|
|
14
|
+
# than it was, and gave them no way to see which locks were real.
|
|
15
|
+
class List < Base
|
|
16
|
+
desc "Every live lock, and any interrupted work"
|
|
17
|
+
|
|
18
|
+
option :json, type: :boolean, default: false, desc: "Machine-readable output"
|
|
19
|
+
|
|
20
|
+
example ["", "--json"]
|
|
21
|
+
|
|
22
|
+
# @param options [Hash]
|
|
23
|
+
# @return [Manager::Result]
|
|
24
|
+
def call(**options)
|
|
25
|
+
manager = manager(options[:dir])
|
|
26
|
+
result = manager.list
|
|
27
|
+
|
|
28
|
+
if options[:json]
|
|
29
|
+
report_records(result.records, json: true, stale_minutes: manager.stale_minutes)
|
|
30
|
+
else
|
|
31
|
+
report_listing(result.records, manager.stale_minutes)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
finish(result)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# @param records [Array<Record>]
|
|
40
|
+
# @param stale_minutes [Integer]
|
|
41
|
+
def report_listing(records, stale_minutes)
|
|
42
|
+
held, interrupted = records.partition(&:active?)
|
|
43
|
+
|
|
44
|
+
if held.empty?
|
|
45
|
+
say("No locks held.")
|
|
46
|
+
else
|
|
47
|
+
say("Locks held (#{held.size}):")
|
|
48
|
+
report_records(held, json: false, stale_minutes: stale_minutes)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
report_interrupted(interrupted, stale_minutes) if interrupted.any?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Each orphan is followed by its two ways out, on STDERR, so what
|
|
55
|
+
# goes down a pipe is the listing alone.
|
|
56
|
+
#
|
|
57
|
+
# @param records [Array<Record>] orphaned locks
|
|
58
|
+
# @param stale_minutes [Integer]
|
|
59
|
+
def report_interrupted(records, stale_minutes)
|
|
60
|
+
say("Interrupted (#{records.size}):")
|
|
61
|
+
records.each do |record|
|
|
62
|
+
report_records([record], json: false, stale_minutes: stale_minutes)
|
|
63
|
+
advise_interrupted(record)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module Lock
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# What this session holds, and what its sub-agents hold, since those
|
|
10
|
+
# are what its `release-all` would take with it. Never its parent's:
|
|
11
|
+
# a sub-agent that saw those as its own could release them.
|
|
12
|
+
class Mine < Base
|
|
13
|
+
desc "Locks held by this session and its sub-agents"
|
|
14
|
+
|
|
15
|
+
option :json, type: :boolean, default: false, desc: "Machine-readable output"
|
|
16
|
+
|
|
17
|
+
# @param options [Hash]
|
|
18
|
+
# @return [Manager::Result]
|
|
19
|
+
def call(**options)
|
|
20
|
+
manager = manager(options[:dir])
|
|
21
|
+
result = manager.mine
|
|
22
|
+
|
|
23
|
+
if result.records.empty?
|
|
24
|
+
say(options[:json] ? "[]" : "No locks held by #{manager.identity.id}.")
|
|
25
|
+
else
|
|
26
|
+
report_records(result.records, json: options[:json], stale_minutes: manager.stale_minutes)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
finish(result)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module Lock
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# Write down where you are, in the lock itself.
|
|
10
|
+
#
|
|
11
|
+
# The lock and the notes have one lifespan on purpose. A machine that
|
|
12
|
+
# reboots loses the session but not the file, so the next run reads one
|
|
13
|
+
# document and knows what was underway and how far it got.
|
|
14
|
+
class Note < Base
|
|
15
|
+
desc "Record progress inside a lock you hold"
|
|
16
|
+
|
|
17
|
+
argument :scope, required: true, desc: "The path or glob you locked"
|
|
18
|
+
argument :text, required: true, desc: "What just happened"
|
|
19
|
+
|
|
20
|
+
example ["workflow/** 'installer rewritten, specs still red'"]
|
|
21
|
+
|
|
22
|
+
def call(scope:, text:, **options)
|
|
23
|
+
result = manager(options[:dir]).note(scope, text)
|
|
24
|
+
|
|
25
|
+
case result.status
|
|
26
|
+
when :noted then say("NOTED #{result.record.scope}")
|
|
27
|
+
when :not_found then warn_("NOT LOCKED #{scope}, take the lock before writing in it")
|
|
28
|
+
when :refused then warn_("REFUSED: #{result.record.scope} is held by #{result.record.agent_id}")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
finish(result)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module Lock
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# Give a scope back.
|
|
10
|
+
class Release < Base
|
|
11
|
+
desc "Release a lock you hold"
|
|
12
|
+
|
|
13
|
+
argument :scope, required: true, desc: "The path or glob you locked"
|
|
14
|
+
|
|
15
|
+
example ["workflow/**", "lib/agent/lock/cli.rb"]
|
|
16
|
+
|
|
17
|
+
def call(scope:, **options)
|
|
18
|
+
result = manager(options[:dir]).release(scope)
|
|
19
|
+
|
|
20
|
+
case result.status
|
|
21
|
+
when :released then say("RELEASED #{result.record.scope}")
|
|
22
|
+
when :not_found then say("NOT LOCKED #{scope}")
|
|
23
|
+
when :refused
|
|
24
|
+
warn_("REFUSED: #{result.record.scope} is held by #{result.record.agent_id}, not you")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
finish(result)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|