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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/CLAUDE.md +1 -0
  3. data/.envrc +2 -0
  4. data/.rubocop_todo.yml +90 -0
  5. data/.ruby-version +1 -0
  6. data/AGENTS.md +54 -0
  7. data/CHANGELOG.md +48 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +374 -0
  10. data/Rakefile +12 -0
  11. data/exe/agent-lock +8 -0
  12. data/exe/alock +9 -0
  13. data/justfile +127 -0
  14. data/lib/agent/lock/cli/commands/acquire.rb +87 -0
  15. data/lib/agent/lock/cli/commands/base.rb +148 -0
  16. data/lib/agent/lock/cli/commands/break.rb +36 -0
  17. data/lib/agent/lock/cli/commands/check.rb +56 -0
  18. data/lib/agent/lock/cli/commands/completion.rb +50 -0
  19. data/lib/agent/lock/cli/commands/list.rb +70 -0
  20. data/lib/agent/lock/cli/commands/mine.rb +35 -0
  21. data/lib/agent/lock/cli/commands/note.rb +37 -0
  22. data/lib/agent/lock/cli/commands/release.rb +33 -0
  23. data/lib/agent/lock/cli/commands/release_all.rb +24 -0
  24. data/lib/agent/lock/cli/commands/resume.rb +36 -0
  25. data/lib/agent/lock/cli/commands/skill.rb +75 -0
  26. data/lib/agent/lock/cli/commands/version.rb +20 -0
  27. data/lib/agent/lock/cli/commands/whoami.rb +83 -0
  28. data/lib/agent/lock/cli.rb +80 -0
  29. data/lib/agent/lock/error.rb +10 -0
  30. data/lib/agent/lock/freeze.rb +109 -0
  31. data/lib/agent/lock/identity.rb +166 -0
  32. data/lib/agent/lock/launcher.rb +126 -0
  33. data/lib/agent/lock/manager.rb +313 -0
  34. data/lib/agent/lock/process_info.rb +60 -0
  35. data/lib/agent/lock/record.rb +216 -0
  36. data/lib/agent/lock/scope.rb +173 -0
  37. data/lib/agent/lock/skill.rb +104 -0
  38. data/lib/agent/lock/store/file_system_store.rb +161 -0
  39. data/lib/agent/lock/store/redis_store.rb +225 -0
  40. data/lib/agent/lock/store.rb +104 -0
  41. data/lib/agent/lock/tree.rb +126 -0
  42. data/lib/agent/lock/version.rb +7 -0
  43. data/lib/agent/lock.rb +30 -0
  44. data/sig/agent/lock.rbs +6 -0
  45. data/skills/agent-lock/SKILL.md +59 -0
  46. 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