yamine 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 03b07451fa5f83423efb747feb496c8617bef2fde5b4f4bf0d595d759efd1083
4
- data.tar.gz: 64fd53920c5f4740402982dc9f56f07a835c83fb5c2a7eb741c636573b69cfad
3
+ metadata.gz: de59fb684553be7e33ba1a9ea4688bb075a3147ae6225cdc634818c869900163
4
+ data.tar.gz: 920ac5409e8e0803ed3e3d8ba01663ea03f1543db30e4921585ef6bfdf0d33be
5
5
  SHA512:
6
- metadata.gz: 73b3fbdd108a91942ba53c29a41cd73825d3f3ea11a24ebc60c42786a792dc74553fe67938b28b2d4787011b021542bf17a633713d1178caa231a359bdd344a9
7
- data.tar.gz: 315127dad685afebc614c3c76d8e8ecda53413aef997cea0e82730f8a169cbb066a4bcedb25c78cf502631062751b9911f8986842beac14440c4a9f25d4c5e9c
6
+ metadata.gz: 8f0cb59209724dd41946c4e0e82c6e2aa01ac9e49575bf461610ed525d98d50fb1566260cf83f7a0b50e19cffd4ab9d6658e372d1c094529e12276e256541161
7
+ data.tar.gz: 8bb784a546f91a27c5a9b00584fbe14560022d655acd861d5bfdba13db8f187b70913fd129b46b73948068d17c1affca556fd0eb4a67690074d74dbcfdb96214
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yamine
4
+ # Agent identity for multi-agent codebases.
5
+ #
6
+ # Every route records which agent booted it so concurrent agents can
7
+ # coexist: list shows owners, conflicts name the other agent, quotas
8
+ # are enforced per agent, and stop/prune can target one agent without
9
+ # touching another's live backends. Identity comes from YAMINE_AGENT
10
+ # (set by the harness per session); without it we fall back to
11
+ # USER@HOST so humans sharing a machine still get distinct owners.
12
+ module Agent
13
+ module_function
14
+
15
+ def name
16
+ env = ENV["YAMINE_AGENT"]
17
+ return env.strip unless env.nil? || env.strip.empty?
18
+
19
+ user = ENV["USER"] || ENV["LOGNAME"] || "unknown"
20
+ host = begin
21
+ require "socket"
22
+ Socket.gethostname
23
+ rescue StandardError
24
+ "localhost"
25
+ end
26
+ "#{user}@#{host}"
27
+ end
28
+
29
+ # Default per-agent route cap. Generous: N agents x M services must
30
+ # fit in the ephemeral port range with headroom. Overridable via
31
+ # YAMINE_MAX_ROUTES (0 disables the cap).
32
+ DEFAULT_MAX_ROUTES = 32
33
+
34
+ def max_routes
35
+ raw = ENV["YAMINE_MAX_ROUTES"]
36
+ return DEFAULT_MAX_ROUTES if raw.nil? || raw.strip.empty?
37
+
38
+ Integer(raw)
39
+ rescue ArgumentError
40
+ DEFAULT_MAX_ROUTES
41
+ end
42
+ end
43
+ end
@@ -18,6 +18,9 @@ module Yamine
18
18
  variant = ENV["YAMINE_VARIANT"]
19
19
  opts = ctx.parse_flags(args, %i[variant tld force])
20
20
  resolved = resolve!(ctx, variant: opts[:variant] || variant, tld: opts[:tld])
21
+ # Ownership gate before any side effects: no proxy spawn, no
22
+ # port allocation when we'd refuse anyway.
23
+ check_worktree_ownership!(ctx, resolved, force: opts[:force])
21
24
  ensure_proxy!(ctx)
22
25
  boot_all(ctx, resolved, opts)
23
26
  end
@@ -60,9 +63,11 @@ module Yamine
60
63
  puts "--"
61
64
 
62
65
  processes.each do |proc_name, entry|
63
- next if entry["proxy"] == false
64
-
65
66
  hostname = Resolver.hostname_for(resolved, proc_name)
67
+ if entry["proxy"] == false
68
+ boot_background(ctx, runner, resolved, proc_name, entry, opts, children)
69
+ next
70
+ end
66
71
  next unless hostname
67
72
 
68
73
  url = Hostname.url(hostname, port: ctx.proxy_port, tls: ctx.proxy_tls)
@@ -101,9 +106,49 @@ module Yamine
101
106
  ctx.report_unresolved(routes_registered.flat_map { |r| r[:hostnames] })
102
107
 
103
108
  # Supervisor: exit when ANY child dies (loud cleanup).
104
- all_pids = routes_registered.map { |r| r[:app].pid }
105
- trap_cleanup(ctx, routes_registered.flat_map { |r| r[:hostnames] }, all_pids)
106
- supervise_tree(ctx, routes_registered.flat_map { |r| r[:hostnames] }, all_pids)
109
+ all_pids = routes_registered.map { |r| r[:app].pid } + children.map { |c| c[:pid] }
110
+ all_hostnames = routes_registered.flat_map { |r| r[:hostnames] }
111
+ trap_cleanup(ctx, all_hostnames, all_pids)
112
+ supervise_tree(ctx, all_hostnames, all_pids)
113
+ end
114
+
115
+ # A background process (proxy: false) is spawned, logged, and
116
+ # supervised exactly like an HTTP one — it just gets no route.
117
+ def boot_background(ctx, runner, resolved, proc_name, entry, opts, children)
118
+ cmd = entry["cmd"].to_s
119
+ if cmd.match?(Yamine::Procfile::COMPOUND)
120
+ $stderr.puts " [#{proc_name}] ERROR: compound line (&&, ||, |, ;) — run explicitly: yamine run -- #{cmd}"
121
+ return
122
+ end
123
+ port = opts[:app_port] || Ports.find_free
124
+ url = "background://#{resolved.app}/#{proc_name}"
125
+ app = runner.boot_run(name: proc_name, hostname: "#{resolved.app}.#{proc_name}.internal",
126
+ url: url, dir: Dir.pwd, command: ["sh", "-c", cmd], port: port,
127
+ force: opts[:force], rails_dev_host: nil, register: false)
128
+ children << { name: proc_name, pid: app.pid }
129
+ puts " [#{proc_name}] background (pid #{app.pid})"
130
+ end
131
+
132
+ # Refuse to boot over another agent's live routes unless forced.
133
+ # Compares our worktree dir (spec.dir) against the owner's: same
134
+ # dir + same agent is a restart, anything else names the owner.
135
+ def check_worktree_ownership!(ctx, resolved, force:)
136
+ mine = Agent.name
137
+ here = File.expand_path(Dir.pwd)
138
+ Resolver.hostnames(resolved).each do |hostname|
139
+ entry = ctx.store.find(hostname)
140
+ next unless entry
141
+ next unless entry["pid"] != 0 && ProxyControl.pid_alive?(entry["pid"])
142
+ next if entry["agent"] == mine && entry.dig("spec", "dir") == here
143
+ next if force
144
+
145
+ owner = entry["agent"] && !entry["agent"].empty? ? "agent #{entry["agent"].inspect}" : "PID #{entry["pid"]}"
146
+ dir = entry.dig("spec", "dir")
147
+ $stderr.puts "Error: #{hostname} is live and owned by #{owner}#{dir ? " (#{dir})" : ""}."
148
+ $stderr.puts " Work in your own worktree (each branch gets its own URL), or take over explicitly:"
149
+ $stderr.puts " yamine start --force"
150
+ exit 1
151
+ end
107
152
  end
108
153
 
109
154
  def build_env(ctx, resolved, entry)
@@ -8,12 +8,16 @@ module Yamine
8
8
  module_function
9
9
 
10
10
  # yamine get <name> [--service x] [--variant y] [--tld z]
11
+ # yamine get --all [--json]
11
12
  #
12
13
  # Variant and TLDs are inherited from the CURRENT directory's
13
14
  # context (worktree branch, YAMINE_* env, config) so cross-service
14
15
  # wiring works inside a variant: from a fix-ui worktree,
15
16
  # `get backend` -> https://fix-ui.backend.localhost.
16
17
  def get(ctx, args)
18
+ if args.delete("--all")
19
+ return registry(ctx, args)
20
+ end
17
21
  name = args.first
18
22
  raise Error, "Usage: yamine get <name> [--service s] [--variant v] [--tld t]" unless name
19
23
 
@@ -36,7 +40,8 @@ module Yamine
36
40
  { hostname: r["hostname"],
37
41
  url: Hostname.url(r["hostname"], port: port, tls: tls),
38
42
  target: r["target"], kind: r["kind"],
39
- pid: r["pid"], supervised: !r["spec"].nil?,
43
+ pid: r["pid"], agent: r["agent"],
44
+ supervised: !r["spec"].nil?,
40
45
  alive: alive_state(ctx, r) }
41
46
  end
42
47
  if json
@@ -56,6 +61,34 @@ module Yamine
56
61
  puts
57
62
  end
58
63
 
64
+ # Shared discovery: `get --all` lists every live route (any owner)
65
+ # with its URL and agent, so one agent can find another's services
66
+ # without coupling. `--json` emits the same stable keys as list.
67
+ def registry(ctx, args)
68
+ json = args.delete("--json")
69
+ routes = ctx.store.load_routes
70
+ port = ctx.proxy_port
71
+ tls = ctx.proxy_tls
72
+ entries = routes.map do |r|
73
+ { hostname: r["hostname"],
74
+ url: Hostname.url(r["hostname"], port: port, tls: tls),
75
+ agent: r["agent"], alive: alive_state(ctx, r) }
76
+ end
77
+ if json
78
+ require "json"
79
+ puts JSON.generate({ routes: entries })
80
+ return
81
+ end
82
+ if entries.empty?
83
+ puts "No active routes."
84
+ return
85
+ end
86
+ entries.each do |e|
87
+ owner = e[:agent] ? " (#{e[:agent]})" : ""
88
+ puts " #{e[:url]}#{owner}"
89
+ end
90
+ end
91
+
59
92
  def alive_state(ctx, route)
60
93
  if route["pid"] == 0
61
94
  ctx.backend_alive?(route) ? "reachable" : "unreachable"
@@ -69,10 +102,11 @@ module Yamine
69
102
  end
70
103
 
71
104
  def label_for(entry)
105
+ owner = entry[:agent] ? " #{entry[:agent]}" : ""
72
106
  if entry[:pid] == 0
73
- "(alias, #{entry[:alive]})"
107
+ "(alias, #{entry[:alive]}#{owner})"
74
108
  else
75
- "(pid #{entry[:pid]}, #{entry[:alive]})"
109
+ "(pid #{entry[:pid]}, #{entry[:alive]}#{owner})"
76
110
  end
77
111
  end
78
112
 
@@ -84,7 +118,19 @@ module Yamine
84
118
  label_for(entry)
85
119
  end
86
120
 
87
- def prune(ctx, _args)
121
+ def prune(ctx, args)
122
+ if (i = args.index("--agent"))
123
+ agent = args.fetch(i + 1, nil)
124
+ raise Error, "Usage: yamine prune --agent NAME" if agent.nil? || agent.empty?
125
+
126
+ stale = ctx.store.prune_stale(agent: agent)
127
+ if stale.empty?
128
+ puts "No stale routes for agent #{agent.inspect}."
129
+ else
130
+ stale.each { |r| puts "Removed stale route #{r["hostname"]}." }
131
+ end
132
+ return
133
+ end
88
134
  stale = ctx.store.prune_stale
89
135
  if stale.empty?
90
136
  puts "No stale routes."
@@ -124,15 +170,31 @@ module Yamine
124
170
  # Stop the app in the current directory (route + backend).
125
171
  # Exit codes are machine-readable for agents: 0 stopped something,
126
172
  # 2 no route here, 3 route existed but the backend was already gone.
127
- def stop(ctx, _args, out: $stdout)
173
+ # Only our own agent's routes are touched: a foreign-owned live
174
+ # route is reported, never killed (shared backends survive one
175
+ # agent's cleanup). --force overrides and names the previous owner.
176
+ def stop(ctx, args, out: $stdout)
177
+ force = args.delete("--force")
128
178
  resolved = Resolver.resolve(Dir.pwd)
129
179
  hostnames = Resolver.hostnames(resolved)
180
+ mine = Agent.name
130
181
  stopped = []
131
182
  gone = []
183
+ foreign = []
132
184
  hostnames.each do |hostname|
133
185
  entry = ctx.store.find(hostname)
134
186
  next unless entry
135
187
 
188
+ if entry["pid"] != 0 && entry["agent"] && entry["agent"] != mine &&
189
+ ProxyControl.pid_alive?(entry["pid"])
190
+ unless force
191
+ owner = entry["agent"].empty? ? "PID #{entry["pid"]}" : "agent #{entry["agent"].inspect}"
192
+ foreign << "#{hostname} (owned by #{owner})"
193
+ next
194
+ end
195
+ out.puts "Taking over #{hostname} from #{entry["agent"].inspect} (--force)."
196
+ end
197
+
136
198
  backend_pid = ctx.backend_pid_for(entry)
137
199
  if backend_pid && ProxyControl.pid_alive?(backend_pid)
138
200
  begin
@@ -155,6 +217,11 @@ module Yamine
155
217
  stopped.each { |s| out.puts "Stopped #{s}." }
156
218
  return 0
157
219
  end
220
+ if foreign.any?
221
+ out.puts "Skipped live routes owned by other agents: #{foreign.join(", ")}."
222
+ out.puts "Take over explicitly with `yamine stop --force`."
223
+ return 4
224
+ end
158
225
  if gone.any?
159
226
  out.puts "Route existed but the backend was already gone: #{gone.join(", ")}."
160
227
  return 3
data/lib/yamine/errors.rb CHANGED
@@ -4,13 +4,32 @@ module Yamine
4
4
  class Error < StandardError; end
5
5
  class ConfigError < Error; end
6
6
  class RouteConflictError < Error
7
- attr_reader :hostname, :existing_pid
7
+ attr_reader :hostname, :existing_pid, :existing_agent, :existing_dir
8
8
 
9
- def initialize(hostname, existing_pid)
9
+ def initialize(hostname, existing_pid, existing_agent: nil, existing_dir: nil)
10
10
  @hostname = hostname
11
11
  @existing_pid = existing_pid
12
- super("\"#{hostname}\" is already registered by a running process " \
13
- "(PID #{existing_pid}). Use --force to override.")
12
+ @existing_agent = existing_agent
13
+ @existing_dir = existing_dir
14
+ owner = if existing_agent && !existing_agent.empty?
15
+ "agent #{existing_agent.inspect}#{existing_dir ? " (#{existing_dir})" : ""}"
16
+ else
17
+ "PID #{existing_pid}"
18
+ end
19
+ super("\"#{hostname}\" is already registered by a running #{owner}. " \
20
+ "Use --force to override.")
21
+ end
22
+ end
23
+ class QuotaExceededError < Error
24
+ attr_reader :agent, :limit, :count
25
+
26
+ def initialize(agent, limit, count)
27
+ @agent = agent
28
+ @limit = limit
29
+ @count = count
30
+ super("Agent #{agent.inspect} already owns #{count} routes (limit #{limit}). " \
31
+ "Stop something first: `yamine stop`, or prune one agent's orphans " \
32
+ "with `yamine prune --agent NAME`.")
14
33
  end
15
34
  end
16
35
  class ProxyNotRunningError < Error; end
@@ -16,11 +16,11 @@ module Yamine
16
16
  module_function
17
17
 
18
18
  # Path to the yamine executable relative to this file
19
- # (lib/ask/local -> gem root/bin). Used for daemon spawn and
19
+ # (lib/yamine -> gem root/bin). Used for daemon spawn and
20
20
  # service install; both must resolve identically in dev checkouts
21
21
  # and installed gems.
22
22
  def bin_path
23
- File.expand_path("../../../bin/yamine", __dir__)
23
+ File.expand_path("../../bin/yamine", __dir__)
24
24
  end
25
25
 
26
26
  def root?
@@ -4,12 +4,14 @@ require "fileutils"
4
4
  require "json"
5
5
 
6
6
  module Yamine
7
- # Persistent hostname -> backend mapping in ~/.yamine/routes.json.
8
- #
9
- # Entries: {hostname, target, kind, pid} where target is a unix socket
10
- # path (kind "socket") or "127.0.0.1:PORT" (kind "tcp"), and pid 0
11
- # marks a static alias. Guarded by flock; stale PIDs pruned on read.
12
- # Modeled on portless RouteStore and puma-dev's AppPool registry.
7
+ # Persistent hostname -> backend mapping in ~/.yamine/routes.json.
8
+ #
9
+ # Entries: {hostname, target, kind, pid, agent} where target is a unix
10
+ # socket path (kind "socket") or "127.0.0.1:PORT" (kind "tcp"), pid 0
11
+ # marks a static alias, and agent names the owner (YAMINE_AGENT or
12
+ # USER@HOST fallback) so concurrent agents can coexist. Guarded by
13
+ # flock; stale PIDs pruned on read.
14
+ # Modeled on portless RouteStore and puma-dev's AppPool registry.
13
15
  class RouteStore
14
16
  FILE_MODE = 0o644
15
17
  DIR_MODE = 0o755
@@ -71,13 +73,22 @@ module Yamine
71
73
  # Returns killed pid when force takes over a live route.
72
74
  # `spec` marks daemon-supervised managed apps ({dir: ...}); the
73
75
  # proxy supervisor may idle-kill and boot-on-request those backends.
74
- def add_route(hostname, target, pid, kind:, force: false, spec: nil)
76
+ # `agent` records the owner (defaults to Agent.name); foreign-owned
77
+ # live routes can only be taken with force: true, and the conflict
78
+ # error names the other agent plus its worktree dir.
79
+ def add_route(hostname, target, pid, kind:, force: false, spec: nil, agent: nil)
80
+ agent ||= Agent.name
75
81
  killed = nil
76
82
  with_lock do
77
83
  routes = load_routes(prune: true)
84
+ enforce_quota!(routes, agent, hostname)
78
85
  existing = routes.find { |r| r["hostname"] == hostname }
79
86
  if existing && existing["pid"] != pid && alive?(existing["pid"])
80
- raise RouteConflictError.new(hostname, existing["pid"]) unless force
87
+ unless force
88
+ raise RouteConflictError.new(hostname, existing["pid"],
89
+ existing_agent: existing["agent"],
90
+ existing_dir: existing.dig("spec", "dir"))
91
+ end
81
92
 
82
93
  begin
83
94
  Process.kill("TERM", existing["pid"])
@@ -87,7 +98,8 @@ module Yamine
87
98
  end
88
99
  end
89
100
  routes.reject! { |r| r["hostname"] == hostname }
90
- entry = { "hostname" => hostname, "target" => target, "kind" => kind, "pid" => pid }
101
+ entry = { "hostname" => hostname, "target" => target, "kind" => kind,
102
+ "pid" => pid, "agent" => agent }
91
103
  entry["spec"] = spec if spec
92
104
  routes << entry
93
105
  save_routes(routes)
@@ -95,33 +107,52 @@ module Yamine
95
107
  killed
96
108
  end
97
109
 
98
- def remove_route(hostname, owner_pid: nil)
99
- with_lock do
100
- routes = load_routes(prune: true)
101
- routes.reject! do |r|
102
- r["hostname"] == hostname && (owner_pid.nil? || r["pid"] == owner_pid)
103
- end
104
- save_routes(routes)
105
- end
110
+ # Live routes owned by one agent (for quotas, targeted prune/stop).
111
+ def routes_for(agent)
112
+ load_routes.select { |r| r["agent"] == agent }
106
113
  end
107
114
 
108
- def prune_stale
115
+ # Remove stale entries belonging to one agent only; other agents'
116
+ # routes are untouched even when their owners are dead.
117
+ def prune_stale(agent: nil)
109
118
  stale = []
110
119
  with_lock do
111
120
  all = load_routes_raw
112
121
  alive, dead = all.partition { |r| r["pid"] == 0 || alive?(r["pid"]) }
113
- stale = dead
114
- save_routes(alive) unless dead.empty?
122
+ stale = agent ? dead.select { |r| r["agent"] == agent } : dead
123
+ save_routes(alive + (dead - stale)) unless stale.empty?
115
124
  end
116
125
  stale
117
126
  end
118
127
 
128
+ def remove_route(hostname, owner_pid: nil)
129
+ with_lock do
130
+ routes = load_routes(prune: true)
131
+ routes.reject! do |r|
132
+ r["hostname"] == hostname && (owner_pid.nil? || r["pid"] == owner_pid)
133
+ end
134
+ save_routes(routes)
135
+ end
136
+ end
137
+
119
138
  def find(hostname)
120
139
  load_routes.find { |r| r["hostname"] == hostname }
121
140
  end
122
141
 
123
142
  private
124
143
 
144
+ # Per-agent route cap. Counts the agent's live routes (excluding the
145
+ # hostname being (re)registered, so restarts never trip the quota).
146
+ # Limit 0 disables the cap. Raises QuotaExceededError naming the
147
+ # agent, the count, and the fix.
148
+ def enforce_quota!(routes, agent, hostname)
149
+ limit = Agent.max_routes
150
+ return if limit.zero?
151
+
152
+ count = routes.count { |r| r["agent"] == agent && r["hostname"] != hostname }
153
+ raise QuotaExceededError.new(agent, limit, count) if count >= limit
154
+ end
155
+
125
156
  def valid_route?(value)
126
157
  value.is_a?(Hash) &&
127
158
  value["hostname"].is_a?(String) &&
data/lib/yamine/runner.rb CHANGED
@@ -90,15 +90,18 @@ module Yamine
90
90
  end
91
91
 
92
92
  # Run an arbitrary command with PORT + YAMINE_URL. Returns App.
93
+ # register: false spawns without a route (background processes).
93
94
  def boot_run(name:, hostname:, url:, dir:, command:, port: nil, force: false,
94
- rails_dev_host: nil)
95
+ rails_dev_host: nil, register: true)
95
96
  port ||= Ports.find_free
96
97
  env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host)
97
98
  pid = with_clean_env { spawn(env, *command, chdir: dir) }
98
99
  Process.detach(pid)
99
100
  target = "127.0.0.1:#{port}"
100
- @store.add_route(hostname, target, Process.pid, kind: "tcp", force: force)
101
- write_backend_pid(hostname, pid)
101
+ if register
102
+ @store.add_route(hostname, target, Process.pid, kind: "tcp", force: force)
103
+ write_backend_pid(hostname, pid)
104
+ end
102
105
  App.new(name: name, hostname: hostname, url: url, pid: pid,
103
106
  target: target, kind: "tcp", command: command)
104
107
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/yamine.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "yamine/version"
4
4
  require_relative "yamine/errors"
5
+ require_relative "yamine/agent"
5
6
  require_relative "yamine/sanitize"
6
7
  require_relative "yamine/hostname"
7
8
  require_relative "yamine/inference"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yamine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -82,6 +82,7 @@ files:
82
82
  - bin/yamine
83
83
  - lib/ask/skills/yamine/SKILL.md
84
84
  - lib/yamine.rb
85
+ - lib/yamine/agent.rb
85
86
  - lib/yamine/certs.rb
86
87
  - lib/yamine/cli.rb
87
88
  - lib/yamine/cli/boot.rb