terret-exec 0.1.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.
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Terret
4
+ module Exec
5
+ # This owner already holds `max_terminals` open. Refused rather than
6
+ # quietly reaping the oldest, or the dead ones: a terminal is a live
7
+ # process an agent asked to keep, and deciding on its behalf which one it
8
+ # has finished with is not ours to make. Closing one is the caller's move.
9
+ TerminalLimit = Class.new(Terret::Tools::Failure)
10
+
11
+ # No terminal by that name belongs to this owner — either it was never
12
+ # opened, it was closed, or it belongs to somebody else. All three are the
13
+ # same answer on purpose: which of them is true is not information one
14
+ # owner should be able to learn about another's terminals.
15
+ NoSuchTerminal = Class.new(Terret::Tools::Failure)
16
+
17
+ # The terminal is still registered, but the process behind it has exited.
18
+ # A typed refusal rather than a silent no-op, and rather than the raw
19
+ # Errno::EIO the pty master raises: the caller asked to type into
20
+ # something, nothing received it, and a model reading the result deserves
21
+ # a sentence naming the terminal instead of a device path. #read answers
22
+ # the same situation with nil, because "nothing to say" and "nothing left
23
+ # to say it" are the same shape to a reader.
24
+ TerminalGone = Class.new(Terret::Tools::Failure)
25
+
26
+ # A name this owner is already using. Opening over it would drop the
27
+ # handle to a running process — unreapable, since nothing would hold its
28
+ # pid any more — so the refusal is what keeps disposal honest.
29
+ TerminalExists = Class.new(Terret::Tools::Failure)
30
+
31
+ # ctx[:terminals] — named, long-lived PTYs (plan §6.6; docs/exec.md §2).
32
+ # They outlive a single tool call by design: a REPL or a dev server stays
33
+ # addressable across a turn, which is the whole difference between this
34
+ # seam and ctx[:subprocess]'s one-shot spawn.
35
+ #
36
+ # Names are scoped per owner, not global. `open(name, argv, session: key)`
37
+ # keys the registry on [owner, name], so two agents may each keep a
38
+ # terminal called "repl" without collision, and neither can address (or
39
+ # accidentally close) the other's by guessing its name — a terminal is a
40
+ # live process with the agent's authority, so a shared namespace would be a
41
+ # capability leak between agents, not merely a naming inconvenience.
42
+ # `close_all_for(key)` is then exactly "drop this owner's rows", which is
43
+ # what agent disposal needs. The cap counts per owner for the same reason:
44
+ # it is the limit an agent can see and act on, and its error message can
45
+ # say something true to the caller that hit it.
46
+ class Terminals < Hames::Service
47
+ service_key :terminals
48
+ inject :subprocess
49
+ config_schema max_terminals: { type: Integer, default: 8, doc: "cap on concurrently open terminals" },
50
+ read_timeout: { type: Numeric, default: 0.1,
51
+ doc: "seconds a non-blocking terminal read waits for output" },
52
+ cwd: { type: String,
53
+ doc: "default working directory for opened terminals (default: Dir.pwd)" }
54
+
55
+ # What `open` hands back. The handle itself stays in the registry: every
56
+ # other method is name-addressed, so nothing outside this service can
57
+ # drive a terminal around the cap or the ownership check.
58
+ Terminal = Data.define(:name, :owner, :pid)
59
+
60
+ DEFAULT_SESSION = :default
61
+ DEFAULT_MAX = 8
62
+ CHUNK = 4096
63
+
64
+ # Bounded, so reading a terminal that has nothing to say returns
65
+ # empty-handed instead of holding the turn open until it does.
66
+ DEFAULT_READ_TIMEOUT = 0.1
67
+
68
+ # Bounds the pre-close drain against a child writing without pause.
69
+ DRAIN_BUDGET = 0.1
70
+
71
+ def start(ctx)
72
+ @ctx = ctx
73
+ @open = {} # [owner, name] => PTYHandle
74
+ # Disposing the owning agent reaps every PTY it opened; fork disposal
75
+ # never touches this root-mounted state. Registered via ctx.on so it
76
+ # reverses when this service unloads.
77
+ ctx.on("agent/disposed") { |session_id| close_all_for(session_id) }
78
+ end
79
+
80
+ # The loader calls this on unload. Terminals are processes the harness
81
+ # owns; dropping the registry without reaping them would leak one per
82
+ # name for the life of the host process.
83
+ def stop(_ctx) = close_all
84
+
85
+ # Every knob is read where it is used, so a hot config swap needs nothing
86
+ # re-derived here — including a lowered `max_terminals`, which then bites
87
+ # on the next open rather than closing something already running.
88
+ def reconfigure(_config); end
89
+
90
+ def open(name, argv, session: DEFAULT_SESSION, cwd: nil, env: {})
91
+ name = name.to_s
92
+ owner = session.to_s
93
+ raise TerminalExists, "a terminal named #{name} is already open" if @open.key?([owner, name])
94
+
95
+ if (count = count_for(owner)) >= max
96
+ raise TerminalLimit, "#{count} terminals are already open; close one first (max_terminals: #{max})"
97
+ end
98
+
99
+ handle = @ctx[:subprocess].pty_spawn(argv, cwd: cwd || default_cwd, env: env)
100
+ @open[[owner, name]] = handle
101
+ Terminal.new(name: name, owner: owner, pid: handle.pid)
102
+ end
103
+
104
+ # The liveness check comes first because the write alone would not tell:
105
+ # Linux accepts a write to a pty master whose child is gone and quietly
106
+ # queues it, so only macOS would ever reach the rescue below. The rescue
107
+ # stays for the race the check cannot close — a child dying between the
108
+ # probe and the write, where macOS still raises.
109
+ def input(name, text, session: DEFAULT_SESSION)
110
+ handle = fetch(name, session)
111
+ unless handle.alive?
112
+ raise TerminalGone, "the terminal named #{name} has no live process; close it and open another"
113
+ end
114
+
115
+ handle.write(text)
116
+ rescue Errno::EIO, Errno::EPIPE, IOError
117
+ raise TerminalGone, "the terminal named #{name} has no live process; close it and open another"
118
+ end
119
+
120
+ # "" means the terminal is alive with nothing to say; nil means its child
121
+ # is gone. The entry survives that EOF — reading is not disposal, and the
122
+ # owner still has to close it, which is also what frees its slot.
123
+ def read(name, session: DEFAULT_SESSION, max: CHUNK, timeout: nil)
124
+ fetch(name, session).read(max, timeout: timeout || read_timeout)
125
+ end
126
+
127
+ # Reaps the child and forgets the name. Closing a name that is not open
128
+ # is a no-op rather than an error: disposal runs over sets that may
129
+ # already have been partly closed, and PTYHandle#close is itself
130
+ # idempotent.
131
+ def close(name, session: DEFAULT_SESSION)
132
+ handle = @open.delete([session.to_s, name.to_s]) or return nil
133
+ drain(handle)
134
+ handle.close
135
+ end
136
+
137
+ # The agent-disposal hook: everything this owner opened, closed and
138
+ # forgotten, by the names it used. Another owner's terminals are
139
+ # untouched.
140
+ def close_all_for(session)
141
+ owner = session.to_s
142
+ @open.keys.select { |(o, _n)| o == owner }.map do |(_o, name)|
143
+ close(name, session: owner)
144
+ name
145
+ end
146
+ end
147
+
148
+ def close_all = @open.keys.each { |(owner, name)| close(name, session: owner) }
149
+
150
+ private
151
+
152
+ def max = config[:max_terminals] || DEFAULT_MAX
153
+ def read_timeout = config[:read_timeout] || DEFAULT_READ_TIMEOUT
154
+ def default_cwd = config[:cwd] || Dir.pwd
155
+
156
+ def count_for(owner) = @open.keys.count { |(o, _n)| o == owner }
157
+
158
+ # Empty the terminal before the handle reaps its child. A process killed
159
+ # while its terminal still holds bytes nobody read can get stuck in exit
160
+ # — measured on macOS with a bash in the terminal, reliably, with as
161
+ # little as a startup banner pending — and the reaper's blocking wait
162
+ # would then never return. In the ordinary case this costs one
163
+ # non-blocking read; the budget bounds a child writing without pause.
164
+ def drain(handle)
165
+ deadline = monotonic + DRAIN_BUDGET
166
+ loop do
167
+ chunk = handle.read(CHUNK, timeout: 0)
168
+ return if chunk.nil? || chunk.empty? || monotonic >= deadline
169
+ end
170
+ end
171
+
172
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
173
+
174
+ def fetch(name, session)
175
+ @open[[session.to_s, name.to_s]] or
176
+ raise NoSuchTerminal, "no terminal named #{name} is open"
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "terret"
5
+ rescue LoadError
6
+ require_relative "../../../terret-core/lib/terret" # monorepo path source
7
+ end
8
+
9
+ require_relative "exec/fs"
10
+ require_relative "exec/sandbox_none"
11
+ require_relative "exec/subprocess"
12
+ require_relative "exec/shell"
13
+ require_relative "exec/terminals"
14
+ require_relative "exec/jobs"
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: terret-exec
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Obie Fernandez
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: terret-core
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ description: 'ctx[:fs]: workspace-contained file ops (read/write/edit/stat/glob) behind
27
+ an fs/authorize waterfall, with realpath-based containment that fails closed on
28
+ traversal and symlink escapes. ctx[:subprocess] spawns and captures under the fiber
29
+ scheduler with cooperative cancellation; ctx[:shell] keeps a bash per agent alive
30
+ across calls; ctx[:terminals] holds named long-lived PTYs; ctx[:jobs] runs a command
31
+ past the turn that started it; and every argv reaches a process through the ctx[:sandbox]
32
+ seam. Zero runtime dependencies beyond stdlib.'
33
+ email:
34
+ - obiefernandez@gmail.com
35
+ executables: []
36
+ extensions: []
37
+ extra_rdoc_files: []
38
+ files:
39
+ - lib/terret/exec.rb
40
+ - lib/terret/exec/fs.rb
41
+ - lib/terret/exec/jobs.rb
42
+ - lib/terret/exec/sandbox_none.rb
43
+ - lib/terret/exec/shell.rb
44
+ - lib/terret/exec/subprocess.rb
45
+ - lib/terret/exec/terminals.rb
46
+ homepage: https://terret.org
47
+ licenses:
48
+ - MIT
49
+ metadata:
50
+ homepage_uri: https://terret.org
51
+ source_code_uri: https://github.com/terret-org/terret
52
+ bug_tracker_uri: https://github.com/terret-org/terret/issues
53
+ rubygems_mfa_required: 'true'
54
+ rdoc_options: []
55
+ require_paths:
56
+ - lib
57
+ required_ruby_version: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '4.0'
62
+ required_rubygems_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '0'
67
+ requirements: []
68
+ rubygems_version: 4.0.16
69
+ specification_version: 4
70
+ summary: 'The execution-world gem for Terret: workspace-scoped filesystem and process
71
+ seams'
72
+ test_files: []