yamine 0.3.0 → 0.5.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: 0c28579a9f53f3078412006a6f697d366cd7f0b2f897e7cf52025b742b2d5ba2
4
+ data.tar.gz: e7c826ffece34cf08ac88fcb31d1613418a9f3f5713badb08f6725937d906409
5
5
  SHA512:
6
- metadata.gz: 73b3fbdd108a91942ba53c29a41cd73825d3f3ea11a24ebc60c42786a792dc74553fe67938b28b2d4787011b021542bf17a633713d1178caa231a359bdd344a9
7
- data.tar.gz: 315127dad685afebc614c3c76d8e8ecda53413aef997cea0e82730f8a169cbb066a4bcedb25c78cf502631062751b9911f8986842beac14440c4a9f25d4c5e9c
6
+ metadata.gz: e1a77d9dad1424f4cdbd0fa351224f626a858e986c6300063a70650dc7c02007f8ea8cbb83e2658a5f06e1a3ff8ba091986ce1480c28e510d9a096c287cc596d
7
+ data.tar.gz: 9194bc35c52acdb669a177bb758af0539c2983056c8e7e4dbd700d937ea850a2947708fef0315d8e2d6281b33850bb96a2de4f26abb0878a2b1b8d3db91cd777
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.5.0] — Unreleased
4
+
5
+ Per-worktree databases: every worktree directory gets its own database
6
+ so concurrent agents never share tables.
7
+
8
+ ### Added
9
+
10
+ - `Yamine::Database`: `<sanitized-dir-basename>_<env>` naming
11
+ (`/code/myapp-fix` → `myapp_fix_development`; main keeps the bare
12
+ name), 63-byte Postgres cap with hash-suffix truncation, collision
13
+ guard via state map (silent sharing would be the bug this kills).
14
+ - Boot resolves the DB once, creates it (`createdb`/`mysqladmin`,
15
+ never raises — warns and shares template on failure), runs the app's
16
+ schema-load (`db.schema_load` in config, Rails default, else nothing),
17
+ and injects `DATABASE_URL` into every process. SQLite left alone.
18
+ - `yamine db list|create|drop` and `yamine worktree list|clean`
19
+ (orphan reaping for deleted worktrees). Branch plays no part —
20
+ directories are stable, branches hop.
21
+
3
22
  ## [0.3.0] — Unreleased
4
23
 
5
24
  Renamed from `ask-local` to **`yamine`** (يمين, "right hand" — the local
@@ -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
@@ -59,10 +62,23 @@ module Yamine
59
62
  puts "yamine (#{service})"
60
63
  puts "--"
61
64
 
62
- processes.each do |proc_name, entry|
63
- next if entry["proxy"] == false
65
+ # Per-worktree database: one database per directory so concurrent
66
+ # agents never share tables. Resolved once, injected into every
67
+ # process as DATABASE_URL, created on demand with the app's own
68
+ # schema-load command. SQLite needs nothing (relative paths
69
+ # already isolate); the branch plays no part (dirs are stable,
70
+ # branches hop).
71
+ db_name = Database.name_for(Dir.pwd, env: rails_env,
72
+ state_dir: ctx.store.dir)
73
+ db_url = setup_database(ctx, resolved, db_name)
74
+ puts " [db] #{db_name}" if db_url
64
75
 
76
+ processes.each do |proc_name, entry|
65
77
  hostname = Resolver.hostname_for(resolved, proc_name)
78
+ if entry["proxy"] == false
79
+ boot_background(ctx, runner, resolved, proc_name, entry, opts, children, db_url)
80
+ next
81
+ end
66
82
  next unless hostname
67
83
 
68
84
  url = Hostname.url(hostname, port: ctx.proxy_port, tls: ctx.proxy_tls)
@@ -82,9 +98,11 @@ module Yamine
82
98
  shell_cmd = ["sh", "-c", cmd]
83
99
  # Always allow the proxied hostname in Rails dev (Rails ignores
84
100
  # this env var when not a Rails app — safe for every framework).
101
+ # DATABASE_URL points at this worktree's own database; non-Ruby
102
+ # frameworks that honor it get isolation for free, others ignore it.
85
103
  app = runner.boot_run(name: proc_name, hostname: hostname, url: url,
86
104
  dir: Dir.pwd, command: shell_cmd, port: port, force: opts[:force],
87
- rails_dev_host: hostname)
105
+ rails_dev_host: hostname, database_url: db_url)
88
106
  register_all(ctx, hostnames, app, force: opts[:force],
89
107
  spec: { "dir" => File.expand_path(Dir.pwd), "proc" => proc_name })
90
108
  routes_registered << { hostnames: hostnames, app: app }
@@ -101,9 +119,50 @@ module Yamine
101
119
  ctx.report_unresolved(routes_registered.flat_map { |r| r[:hostnames] })
102
120
 
103
121
  # 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)
122
+ all_pids = routes_registered.map { |r| r[:app].pid } + children.map { |c| c[:pid] }
123
+ all_hostnames = routes_registered.flat_map { |r| r[:hostnames] }
124
+ trap_cleanup(ctx, all_hostnames, all_pids)
125
+ supervise_tree(ctx, all_hostnames, all_pids)
126
+ end
127
+
128
+ # A background process (proxy: false) is spawned, logged, and
129
+ # supervised exactly like an HTTP one — it just gets no route.
130
+ def boot_background(ctx, runner, resolved, proc_name, entry, opts, children, db_url)
131
+ cmd = entry["cmd"].to_s
132
+ if cmd.match?(Yamine::Procfile::COMPOUND)
133
+ $stderr.puts " [#{proc_name}] ERROR: compound line (&&, ||, |, ;) — run explicitly: yamine run -- #{cmd}"
134
+ return
135
+ end
136
+ port = opts[:app_port] || Ports.find_free
137
+ url = "background://#{resolved.app}/#{proc_name}"
138
+ app = runner.boot_run(name: proc_name, hostname: "#{resolved.app}.#{proc_name}.internal",
139
+ url: url, dir: Dir.pwd, command: ["sh", "-c", cmd], port: port,
140
+ force: opts[:force], rails_dev_host: nil, register: false,
141
+ database_url: db_url)
142
+ children << { name: proc_name, pid: app.pid }
143
+ puts " [#{proc_name}] background (pid #{app.pid})"
144
+ end
145
+
146
+ # Refuse to boot over another agent's live routes unless forced.
147
+ # Compares our worktree dir (spec.dir) against the owner's: same
148
+ # dir + same agent is a restart, anything else names the owner.
149
+ def check_worktree_ownership!(ctx, resolved, force:)
150
+ mine = Agent.name
151
+ here = File.expand_path(Dir.pwd)
152
+ Resolver.hostnames(resolved).each do |hostname|
153
+ entry = ctx.store.find(hostname)
154
+ next unless entry
155
+ next unless entry["pid"] != 0 && ProxyControl.pid_alive?(entry["pid"])
156
+ next if entry["agent"] == mine && entry.dig("spec", "dir") == here
157
+ next if force
158
+
159
+ owner = entry["agent"] && !entry["agent"].empty? ? "agent #{entry["agent"].inspect}" : "PID #{entry["pid"]}"
160
+ dir = entry.dig("spec", "dir")
161
+ $stderr.puts "Error: #{hostname} is live and owned by #{owner}#{dir ? " (#{dir})" : ""}."
162
+ $stderr.puts " Work in your own worktree (each branch gets its own URL), or take over explicitly:"
163
+ $stderr.puts " yamine start --force"
164
+ exit 1
165
+ end
107
166
  end
108
167
 
109
168
  def build_env(ctx, resolved, entry)
@@ -121,6 +180,64 @@ module Yamine
121
180
  env
122
181
  end
123
182
 
183
+ # Rails env for database naming: RAILS_ENV when set, else development.
184
+ # Non-Rails apps ignore it (their DATABASE_URL template decides).
185
+ def rails_env
186
+ ENV.fetch("RAILS_ENV", "development")
187
+ end
188
+
189
+ # Resolve this worktree's database, create it if missing, and run
190
+ # the app's schema-load command on first creation. Returns the
191
+ # DATABASE_URL for injection, or nil when there's nothing to do
192
+ # (no template URL, sqlite, or the app opted out via db: false).
193
+ def setup_database(ctx, resolved, db_name)
194
+ return nil if resolved.processes["db"] == false
195
+
196
+ template = ENV["DATABASE_URL"] || database_template_from_config(resolved)
197
+ return nil if template.nil? || template.strip.empty?
198
+
199
+ url = Database.url_for(db_name, template)
200
+ return nil unless url
201
+
202
+ if Database.ensure_exists(db_name, template)
203
+ puts " [db] created #{db_name}" unless database_exists?(db_name, template)
204
+ run_schema_load(resolved, url)
205
+ else
206
+ $stderr.puts " [db] WARNING: could not create #{db_name} — processes share the template database."
207
+ return nil
208
+ end
209
+ url
210
+ end
211
+
212
+ def database_exists?(name, template)
213
+ # ensure_exists is idempotent; this second call is cheap (psql -lqt)
214
+ # and tells us whether to print "created" vs nothing.
215
+ Database.ensure_exists(name, template)
216
+ end
217
+
218
+ def database_template_from_config(resolved)
219
+ env = resolved.processes.values.map { |e| e["env"] || {} }
220
+ clear = env.map { |e| e["clear"] || {} }.reduce({}, :merge)
221
+ clear["DATABASE_URL"]
222
+ end
223
+
224
+ # Schema-load on first boot uses the app's own command when declared
225
+ # (db.schema_load in config/local.yml), else a Rails default when a
226
+ # Rails app is detected, else nothing (frameworks without a
227
+ # schema concept need no step).
228
+ def run_schema_load(resolved, url)
229
+ cmd = resolved.processes.dig("db", "schema_load") ||
230
+ ("bin/rails db:schema:load" if rails_app?)
231
+ return unless cmd
232
+
233
+ system({ "DATABASE_URL" => url, "RAILS_ENV" => rails_env },
234
+ "sh", "-c", cmd, chdir: Dir.pwd, out: File::NULL, err: File::NULL)
235
+ end
236
+
237
+ def rails_app?
238
+ File.file?(File.join(Dir.pwd, "config", "application.rb"))
239
+ end
240
+
124
241
  def supervise_tree(ctx, hostnames, pids)
125
242
  loop do
126
243
  sleep 0.5
@@ -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
@@ -763,6 +763,134 @@ module Yamine
763
763
  puts " - #{app}-#{slug}.#{domain}"
764
764
  puts " # Prefer Kamal multi-host for production; single-host preview above is fine for ephemeral branches."
765
765
  end
766
+
767
+ # Per-worktree databases: list/create/drop, plus orphan cleanup.
768
+ # `db list` shows every claimed database with its worktree dir.
769
+ # `db create` resolves this worktree's name and creates it.
770
+ # `db drop <name>` drops one database (refuses without --force
771
+ # when its worktree still exists).
772
+ def db(ctx, args)
773
+ sub = args.first
774
+ case sub
775
+ when "list", nil then db_list(ctx)
776
+ when "create" then db_create(ctx)
777
+ when "drop" then db_drop(ctx, args[1..] || [])
778
+ else raise Error, "Usage: yamine db [list|create|drop <name> [--force]]"
779
+ end
780
+ end
781
+
782
+ def db_list(ctx)
783
+ map = Database.load_map(ctx.store.dir)
784
+ if map.empty?
785
+ puts "No worktree databases claimed yet."
786
+ puts "Boot an app with a DATABASE_URL template to create one."
787
+ return
788
+ end
789
+ map.each do |name, info|
790
+ alive = File.directory?(info["dir"]) ? "" : " (worktree gone)"
791
+ puts " #{name} -> #{info["dir"]}#{alive}"
792
+ end
793
+ end
794
+
795
+ def db_create(ctx)
796
+ resolved = Resolver.resolve(Dir.pwd)
797
+ name = Database.name_for(Dir.pwd, env: boot_env_name,
798
+ state_dir: ctx.store.dir)
799
+ template = ENV["DATABASE_URL"]
800
+ unless template && !template.strip.empty?
801
+ $stderr.puts "Error: set DATABASE_URL (template with host/user; database segment is replaced)."
802
+ exit 1
803
+ end
804
+ if Database.ensure_exists(name, template)
805
+ puts "Database #{name} ready."
806
+ else
807
+ $stderr.puts "Error: could not create #{name}. Check the database server is running."
808
+ exit 1
809
+ end
810
+ end
811
+
812
+ def db_drop(ctx, args)
813
+ force = args.delete("--force")
814
+ name = args.first
815
+ raise Error, "Usage: yamine db drop <name> [--force]" unless name
816
+
817
+ map = Database.load_map(ctx.store.dir)
818
+ info = map[name]
819
+ if info && File.directory?(info["dir"]) && !force
820
+ $stderr.puts "Error: worktree #{info["dir"]} still exists. Use --force to drop #{name} anyway."
821
+ exit 1
822
+ end
823
+ if drop_database(ctx, name)
824
+ map.delete(name)
825
+ Database.save_map(ctx.store.dir, map)
826
+ puts "Dropped #{name}."
827
+ else
828
+ $stderr.puts "Error: could not drop #{name}."
829
+ exit 1
830
+ end
831
+ end
832
+
833
+ def drop_database(_ctx, name)
834
+ template = ENV["DATABASE_URL"]
835
+ return false unless template && !template.strip.empty?
836
+
837
+ case Database.adapter_for(template)
838
+ when :postgres
839
+ uri = URI.parse(template)
840
+ _out, status = Open3.capture2("dropdb", name, env: Database.pg_env(uri))
841
+ status.success?
842
+ when :mysql
843
+ uri = URI.parse(template)
844
+ args = ["-h", uri.host || "127.0.0.1", "-u",
845
+ URI.decode_www_form_component(uri.user || "root")]
846
+ _out, status = Open3.capture2("mysqladmin", *args, "drop", name, "-f")
847
+ status.success?
848
+ else
849
+ false
850
+ end
851
+ rescue SystemCallError
852
+ false
853
+ end
854
+
855
+ def boot_env_name
856
+ ENV.fetch("RAILS_ENV", "development")
857
+ end
858
+
859
+ # Worktree helpers: list claimed databases with liveness, clean
860
+ # drops databases whose worktree dirs are gone.
861
+ def worktree(ctx, args)
862
+ sub = args.first
863
+ case sub
864
+ when "list", nil
865
+ map = Database.load_map(ctx.store.dir)
866
+ if map.empty?
867
+ puts "No worktree databases claimed yet."
868
+ return
869
+ end
870
+ map.each do |name, info|
871
+ alive = File.directory?(info["dir"]) ? "alive" : "gone"
872
+ puts " #{name} #{info["dir"]} (#{alive})"
873
+ end
874
+ when "clean"
875
+ orphaned = Database.orphaned(ctx.store.dir)
876
+ if orphaned.empty?
877
+ puts "No orphaned worktree databases."
878
+ return
879
+ end
880
+ orphaned.each do |name, _info|
881
+ if drop_database(ctx, name)
882
+ puts "Dropped orphaned #{name}."
883
+ else
884
+ $stderr.puts "Could not drop #{name} — remove manually."
885
+ end
886
+ end
887
+ map = Database.load_map(ctx.store.dir)
888
+ orphaned.each_key { |name| map.delete(name) }
889
+ Database.save_map(ctx.store.dir, map)
890
+ else
891
+ raise Error, "Usage: yamine worktree [list|clean]"
892
+ end
893
+ end
766
894
  end
767
895
  end
768
896
  end
data/lib/yamine/cli.rb CHANGED
@@ -10,7 +10,7 @@ module Yamine
10
10
  # In non-interactive environments (no TTY or CI=1) we fail early with
11
11
  # a clear message instead of prompting (portless lesson).
12
12
  class CLI
13
- SUBCOMMANDS = %w[run get alias hosts list doctor trust clean prune proxy service sudoers kamal stop restart log status open setup start init].freeze
13
+ SUBCOMMANDS = %w[run get alias hosts list doctor trust clean prune proxy service sudoers kamal stop restart log status open setup start init db worktree].freeze
14
14
 
15
15
  def self.run(argv)
16
16
  new.run(argv)
@@ -47,6 +47,8 @@ module Yamine
47
47
  when "setup" then SystemCommand.setup(ctx, args)
48
48
  when "init" then SystemCommand.init(ctx, args)
49
49
  when "start" then SystemCommand.start(ctx, args)
50
+ when "db" then SystemCommand.db(ctx, args)
51
+ when "worktree" then SystemCommand.worktree(ctx, args)
50
52
  when "kamal" then SystemCommand.kamal(ctx, args)
51
53
  when "stop"
52
54
  exit RoutesCommand.stop(ctx, args)
@@ -81,6 +83,8 @@ module Yamine
81
83
  yamine trust Add local CA to trust store
82
84
  yamine clean Remove state and hosts entries
83
85
  yamine prune Remove stale routes
86
+ yamine db list|create|drop Per-worktree databases
87
+ yamine worktree list|clean Worktree databases + orphan cleanup
84
88
  yamine proxy start|stop Control the proxy
85
89
  yamine service install|status|uninstall OS startup service
86
90
  yamine sudoers Print scoped passwordless-sudo rules for port 443
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "open3"
7
+
8
+ module Yamine
9
+ # Per-worktree databases. Every worktree directory gets its own database
10
+ # so concurrent agents never share tables: no migration races, no seed
11
+ # pollution, no schema drift between branches.
12
+ #
13
+ # Naming: <sanitized-dir-basename>_<env> — e.g. /Users/kaka/code/myapp-fix
14
+ # becomes myapp_fix_development. The branch a worktree has checked out is
15
+ # irrelevant and mutable, so it plays no part; the directory is stable.
16
+ # The main checkout keeps the bare <basename>_<env> name. Postgres
17
+ # identifiers cap at 63 bytes; overlong names truncate with a hash
18
+ # suffix (same rule as hostname labels).
19
+ #
20
+ # Collision guard: two directories can sanitize identically
21
+ # (myapp-fix vs myapp_fix). A state file records which directory
22
+ # claimed which name first; on collision the newcomer gets a short
23
+ # hash suffix. Silent sharing would be exactly the bug this exists to
24
+ # kill, so the check is mandatory, not best-effort.
25
+ #
26
+ # Framework-agnostic: yamine injects DATABASE_URL and never touches
27
+ # framework files. SQLite needs nothing (relative paths already isolate
28
+ # per worktree). Creation runs the app's own schema-load command from
29
+ # config/local.yml — never assumed.
30
+ module Database
31
+ module_function
32
+
33
+ MAX_IDENTIFIER_BYTES = 63
34
+ STATE_FILE = "databases.json"
35
+
36
+ # Database name for a worktree dir + env (development/test).
37
+ # Main checkout (no worktree marker) keeps the bare name.
38
+ def name_for(dir, env: "development", state_dir: nil)
39
+ base = sanitize(File.basename(File.expand_path(dir)))
40
+ name = "#{base}_#{env}"
41
+ name = truncate(name)
42
+ claimed = claim(name, dir, state_dir: state_dir)
43
+ claimed || "#{truncate("#{base}_#{short_hash(dir)}")}_#{env}"
44
+ end
45
+
46
+ # The main checkout is the one whose .git is a directory (linked
47
+ # worktrees have a .git FILE pointing into /worktrees/). Main keeps
48
+ # the bare name; only linked worktrees get suffixed names... in
49
+ # practice every dir gets the mapping, but main's mapping is itself.
50
+ def main_checkout?(dir)
51
+ File.directory?(File.join(File.expand_path(dir), ".git"))
52
+ end
53
+
54
+ def sanitize(name)
55
+ name.downcase.gsub(/[^a-z0-9_]/, "_").gsub(/_+/, "_").gsub(/\A_+|_+\z/, "")
56
+ end
57
+
58
+ def truncate(name)
59
+ return name if name.bytesize <= MAX_IDENTIFIER_BYTES
60
+
61
+ hash = Digest::SHA256.hexdigest(name)[0, 6]
62
+ prefix = name.byteslice(0, MAX_IDENTIFIER_BYTES - 7).gsub(/_+\z/, "")
63
+ "#{prefix}_#{hash}"
64
+ end
65
+
66
+ def short_hash(dir)
67
+ Digest::SHA256.hexdigest(File.expand_path(dir))[0, 6]
68
+ end
69
+
70
+ # Record dir -> name claim; returns the name if ours (or unclaimed),
71
+ # nil if another dir claimed it first (caller must suffix).
72
+ def claim(name, dir, state_dir: nil)
73
+ return name unless state_dir
74
+
75
+ dir = File.expand_path(dir)
76
+ map = load_map(state_dir)
77
+ if map.key?(name) && map[name]["dir"] != dir
78
+ return nil
79
+ end
80
+ map[name] = { "dir" => dir, "claimed_at" => Time.now.utc.iso8601 }
81
+ save_map(state_dir, map)
82
+ name
83
+ rescue SystemCallError
84
+ name
85
+ end
86
+
87
+ def load_map(state_dir)
88
+ path = File.join(state_dir, STATE_FILE)
89
+ return {} unless File.file?(path)
90
+
91
+ JSON.parse(File.read(path))
92
+ rescue JSON::ParserError, SystemCallError
93
+ {}
94
+ end
95
+
96
+ def save_map(state_dir, map)
97
+ FileUtils.mkdir_p(state_dir)
98
+ File.write(File.join(state_dir, STATE_FILE), JSON.pretty_generate(map))
99
+ rescue SystemCallError
100
+ nil
101
+ end
102
+
103
+ # Databases whose worktree dirs no longer exist (for `worktree clean`).
104
+ def orphaned(state_dir)
105
+ load_map(state_dir).select { |_, v| !File.directory?(v["dir"]) }
106
+ end
107
+
108
+ # Adapter detection from DATABASE_URL scheme. Returns :postgres,
109
+ # :mysql, :sqlite, or :unknown. Only postgres/mysql get create +
110
+ # DATABASE_URL treatment; sqlite is left alone (relative paths
111
+ # already isolate per worktree).
112
+ def adapter_for(database_url)
113
+ str = database_url.to_s.strip
114
+ return :sqlite if str.empty?
115
+
116
+ scheme = str[/\A[a-z0-9+]+/i].to_s.downcase
117
+ case scheme
118
+ when "postgres", "postgresql" then :postgres
119
+ when "mysql", "mysql2" then :mysql
120
+ when "sqlite", "sqlite3" then :sqlite
121
+ else :unknown
122
+ end
123
+ end
124
+
125
+ # Build a DATABASE_URL for name from a template URL (host, port, user
126
+ # preserved; database segment replaced). Returns nil when the template
127
+ # is absent or sqlite.
128
+ def url_for(name, template_url)
129
+ return nil if template_url.nil? || template_url.strip.empty?
130
+ return nil if adapter_for(template_url) == :sqlite
131
+
132
+ uri = URI.parse(template_url)
133
+ uri.path = "/#{name}"
134
+ uri.to_s
135
+ rescue URI::InvalidURIError
136
+ nil
137
+ end
138
+
139
+ # Create the database if missing. Uses createdb/mysqladmin when
140
+ # available; returns true/false, never raises (boot reports, not dies).
141
+ def ensure_exists(name, database_url)
142
+ case adapter_for(database_url)
143
+ when :postgres then ensure_postgres(name, database_url)
144
+ when :mysql then ensure_mysql(name, database_url)
145
+ else false
146
+ end
147
+ end
148
+
149
+ def ensure_postgres(name, database_url)
150
+ uri = URI.parse(database_url)
151
+ env = pg_env(uri)
152
+ _out, status = Open3.capture2(env, "psql", "-lqt")
153
+ return false unless status.success?
154
+
155
+ exists = Open3.capture2(env, "psql", "-lqt")[0].split("\n").any? do |line|
156
+ line.split("|").first.to_s.strip == name
157
+ end
158
+ return true if exists
159
+
160
+ _out, status = Open3.capture2(env, "createdb", name)
161
+ status.success?
162
+ rescue SystemCallError
163
+ false
164
+ end
165
+
166
+ def ensure_mysql(name, database_url)
167
+ uri = URI.parse(database_url)
168
+ args = ["-h", uri.host || "127.0.0.1", "-P", (uri.port || 3306).to_s,
169
+ "-u", URI.decode_www_form_component(uri.user || "root")]
170
+ args += ["-p#{URI.decode_www_form_component(uri.password)}"] if uri.password
171
+ _out, status = Open3.capture2("mysqladmin", *args, "create", name)
172
+ status.success? || already_exists_mysql(args, name)
173
+ rescue SystemCallError
174
+ false
175
+ end
176
+
177
+ def already_exists_mysql(args, name)
178
+ out, status = Open3.capture2("mysql", *args, "-e", "SHOW DATABASES;")
179
+ status.success? && out.split("\n").include?(name)
180
+ rescue SystemCallError
181
+ false
182
+ end
183
+
184
+ def pg_env(uri)
185
+ {
186
+ "PGHOST" => uri.host || "127.0.0.1",
187
+ "PGPORT" => (uri.port || 5432).to_s,
188
+ "PGUSER" => URI.decode_www_form_component(uri.user || ENV["USER"].to_s),
189
+ "PGPASSWORD" => uri.password ? URI.decode_www_form_component(uri.password) : nil
190
+ }.compact
191
+ end
192
+ end
193
+ end
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,20 +90,24 @@ 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, database_url: nil)
95
96
  port ||= Ports.find_free
96
- env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host)
97
+ env = child_env(dir, url: url, port: port, rails_dev_host: rails_dev_host,
98
+ database_url: database_url)
97
99
  pid = with_clean_env { spawn(env, *command, chdir: dir) }
98
100
  Process.detach(pid)
99
101
  target = "127.0.0.1:#{port}"
100
- @store.add_route(hostname, target, Process.pid, kind: "tcp", force: force)
101
- write_backend_pid(hostname, pid)
102
+ if register
103
+ @store.add_route(hostname, target, Process.pid, kind: "tcp", force: force)
104
+ write_backend_pid(hostname, pid)
105
+ end
102
106
  App.new(name: name, hostname: hostname, url: url, pid: pid,
103
107
  target: target, kind: "tcp", command: command)
104
108
  end
105
109
 
106
- def child_env(dir, url:, port:, rails_dev_host: nil)
110
+ def child_env(dir, url:, port:, rails_dev_host: nil, database_url: nil)
107
111
  env = { "YAMINE_URL" => url }
108
112
  env["PORT"] = port.to_s if port
109
113
  env["HOST"] = "127.0.0.1"
@@ -113,6 +117,10 @@ module Yamine
113
117
  # proxied hostname so Rails apps boot behind yamine with zero
114
118
  # config — this replaces the yamine-rails hosts patch.
115
119
  env["RAILS_DEVELOPMENT_HOSTS"] = rails_dev_host if rails_dev_host
120
+ # Per-worktree database: every process in this worktree shares one
121
+ # isolated database. Frameworks that honor DATABASE_URL (Rails,
122
+ # Django, most Node) get isolation for free; others ignore it.
123
+ env["DATABASE_URL"] = database_url if database_url
116
124
  env
117
125
  end
118
126
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Yamine
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/yamine.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "yamine/version"
4
4
  require_relative "yamine/errors"
5
+ require_relative "yamine/agent"
6
+ require_relative "yamine/database"
5
7
  require_relative "yamine/sanitize"
6
8
  require_relative "yamine/hostname"
7
9
  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.5.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
@@ -90,6 +91,7 @@ files:
90
91
  - lib/yamine/cli/system.rb
91
92
  - lib/yamine/command.rb
92
93
  - lib/yamine/config.rb
94
+ - lib/yamine/database.rb
93
95
  - lib/yamine/doctor.rb
94
96
  - lib/yamine/errors.rb
95
97
  - lib/yamine/framework.rb