elelem-server 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1c047e22b438e01221d4a7aa9b49efd98fb0b0482ff4dde16a6cfa9fd0c6b485
4
- data.tar.gz: 8723debc3c293c8653ab25352545f27141fe86323c4e82619356c3e856d3cfaa
3
+ metadata.gz: 8ae17fb98f2f26fa6e0503d295ece4362a3938e4a44a80561111b6dced5a1deb
4
+ data.tar.gz: 4e5e7f0f76f667581ad852dc498bb9f5efa2c56540bfa2af50a3c25fe4f6503a
5
5
  SHA512:
6
- metadata.gz: 2cc1f577a160b939ae7686f719273879a9bf11f0c189256fa1d7b8c89bde8449bc2467b4afed5f50041a2a48c57237d08643657f4a40f9290328b20c7eb61d88
7
- data.tar.gz: f78ec7721020902adb3be65558389ba38886df959e1a5a110e52d152c39370f5c6fe3376e92b63fa01dd721a0638db4e33eca8b426d7bfe83efb0c49f179dd2e
6
+ metadata.gz: 67b211c138c11bc595ce0e6ca7ab30dc7e47e0cf4350eddc0a835c1250e0f9a58607b7e7b2f8b2d5c00469c3bccdd75b212e4562f7e3731d467d1316116247c5
7
+ data.tar.gz: c6aaf041c17653e18900731c2ff78876c72763973e5ab5b45c08901c9de43669f2f54ad53cf70a9268e4ca5cdb650edba3772053df867cce2423697d636d7839
@@ -0,0 +1,61 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch:
7
+
8
+ permissions: {}
9
+
10
+ concurrency:
11
+ group: release
12
+ cancel-in-progress: false
13
+
14
+ jobs:
15
+ test:
16
+ runs-on: ubuntu-latest
17
+ permissions:
18
+ contents: read
19
+ steps:
20
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
21
+ with:
22
+ persist-credentials: false
23
+ - uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0
24
+ with:
25
+ ruby-version: "4.0"
26
+ bundler-cache: true
27
+ - run: bin/test
28
+ - run: bin/lint
29
+
30
+ release-please:
31
+ needs: test
32
+ runs-on: ubuntu-latest
33
+ permissions:
34
+ contents: write
35
+ issues: write
36
+ pull-requests: write
37
+ outputs:
38
+ release_created: ${{ steps.release.outputs.release_created }}
39
+ steps:
40
+ - id: release
41
+ uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
42
+ with:
43
+ config-file: release-please-config.json
44
+ manifest-file: .release-please-manifest.json
45
+
46
+ publish:
47
+ needs: release-please
48
+ if: needs.release-please.outputs.release_created == 'true'
49
+ runs-on: ubuntu-latest
50
+ permissions:
51
+ contents: write
52
+ id-token: write
53
+ steps:
54
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
55
+ with:
56
+ persist-credentials: false
57
+ - uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0
58
+ with:
59
+ ruby-version: "4.0"
60
+ bundler-cache: true
61
+ - uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1.4.1
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.2.1"
3
+ }
data/.rubocop.yml ADDED
@@ -0,0 +1,62 @@
1
+ AllCops:
2
+ NewCops: enable
3
+ TargetRubyVersion: 4.0
4
+ SuggestExtensions: false
5
+ Exclude:
6
+ - "pkg/**/*"
7
+ - "bin/**/*"
8
+ - "vendor/**/*"
9
+ - "fixtures/**/*"
10
+
11
+ Metrics:
12
+ Enabled: false
13
+
14
+ Naming/PredicateMethod:
15
+ AllowedMethods:
16
+ - call
17
+ - run
18
+ - summarize
19
+ - prompt
20
+
21
+ Naming/MethodParameterName:
22
+ AllowedNames:
23
+ - me
24
+ - id
25
+ - as
26
+ - io
27
+
28
+ Style/GlobalVars:
29
+ Exclude:
30
+ - "spec/**/*"
31
+
32
+ Lint/EmptyBlock:
33
+ Exclude:
34
+ - "spec/**/*"
35
+
36
+ Lint/UselessDefaultValueArgument:
37
+ Enabled: false
38
+
39
+ Lint/RedundantRequireStatement:
40
+ Enabled: false
41
+
42
+ Lint/UnusedBlockArgument:
43
+ IgnoreEmptyBlocks: true
44
+ AllowUnusedKeywordArguments: true
45
+
46
+ Style/Documentation:
47
+ Enabled: false
48
+
49
+ Style/FormatStringToken:
50
+ Enabled: false
51
+
52
+ Style/FrozenStringLiteralComment:
53
+ Enabled: true
54
+
55
+ Style/StringLiterals:
56
+ EnforcedStyle: double_quotes
57
+
58
+ Style/StringLiteralsInInterpolation:
59
+ EnforcedStyle: double_quotes
60
+
61
+ Layout/LineLength:
62
+ Max: 120
data/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## [0.2.1](https://github.com/elelem-sh/server/compare/v0.2.0...v0.2.1) (2026-11-15)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * build a Session for Config.apply and Agent#turn ([4ab731c](https://github.com/elelem-sh/server/commit/4ab731c02948dfd0c49d1750e680fea0e9b7de75))
9
+ * correlate prompt/answer by id, close /answer pre-approval bug ([ddae723](https://github.com/elelem-sh/server/commit/ddae723c67ef2a44de4a50869f7cbaeab13c9b66))
10
+ * each browser tab gets its own session, and slash commands run as commands ([elelem-sh/elelem.sh#31](https://github.com/elelem-sh/elelem.sh/issues/31)) ([31a6d96](https://github.com/elelem-sh/server/commit/31a6d965704d7faceb23d143f37dbc58dfae4609))
11
+ * require elelem ~> 0.12, the floor builtins/skills actually need ([af2fdac](https://github.com/elelem-sh/server/commit/af2fdac0462ed4774fbb136361502f60555e05bc))
12
+ * server prompt ids are unique across tabs, a claimed tab cannot be evicted, and a closed tab keeps its place for a minute ([aedcf3c](https://github.com/elelem-sh/server/commit/aedcf3c4565ef18e2c48914abc9dc7c5ece08a72))
13
+ * server tabs build without blocking others, free idle places, survive a reload mid-prompt, and keep the token out of the address bar ([163433d](https://github.com/elelem-sh/server/commit/163433d85a5acd3eba7c891bedabc034f455b144))
14
+ * the server answers a bound IPv6 address, and ends a turn before taking the next ([f453fd2](https://github.com/elelem-sh/server/commit/f453fd293985781446d167ea110eb3249ce56cda))
15
+ * the server defaults to the demo provider, since elelem 0.13.1 removed stub ([e7867bc](https://github.com/elelem-sh/server/commit/e7867bc63e9f13a18cadaae5f51e9c8fc945533e))
16
+ * the server listens on 127.0.0.1 and refuses other hosts, origins and non-JSON posts ([elelem-sh/elelem.sh#1](https://github.com/elelem-sh/elelem.sh/issues/1)) ([54197c4](https://github.com/elelem-sh/server/commit/54197c442ae08cc62c3e5e82d54e2ea4efce892b))
17
+ * the server requires the token it prints at start ([elelem-sh/elelem.sh#1](https://github.com/elelem-sh/elelem.sh/issues/1)) ([c76e69c](https://github.com/elelem-sh/server/commit/c76e69c3d8e96e671fe9c8c577863bd0fc41668a))
18
+ * the server runs one turn at a time and the page waits for it ([elelem-sh/elelem.sh#31](https://github.com/elelem-sh/elelem.sh/issues/31)) ([a7fc5d6](https://github.com/elelem-sh/server/commit/a7fc5d6a48113bb53759ef24bc292cd9ce9caafe))
19
+ * the startup line brackets an IPv6 address so the URL works ([641a803](https://github.com/elelem-sh/server/commit/641a8034fdc1d0763150fa01ed76d7ae52a0376d))
20
+ * the web page renders line breaks, code and bold ([elelem-sh/elelem.sh#30](https://github.com/elelem-sh/elelem.sh/issues/30)) ([d29df97](https://github.com/elelem-sh/server/commit/d29df971dcbc0eb8d8c5b32d7a9b8c2816deffbd))
data/exe/elelem-server CHANGED
@@ -4,14 +4,19 @@
4
4
  require "elelem/server"
5
5
  require "optparse"
6
6
 
7
- provider = ENV.fetch("ELELEM_PROVIDER", "stub")
7
+ provider = ENV.fetch("ELELEM_PROVIDER", "demo")
8
8
  port = 4567
9
+ address = "127.0.0.1"
9
10
 
10
11
  OptionParser.new do |o|
11
12
  o.banner = "Usage: elelem-server [options]"
12
13
  o.on("-p", "--provider NAME", "Provider to use (default: #{provider})") { |v| provider = v }
13
14
  o.on("--port PORT", Integer, "Port to listen on (default: #{port})") { |v| port = v }
14
- o.on("-h", "--help", "Show this help") { puts o; exit }
15
+ o.on("--bind ADDR", "Address to listen on (default: #{address})") { |v| address = v }
16
+ o.on("-h", "--help", "Show this help") do
17
+ puts o
18
+ exit
19
+ end
15
20
  end.parse!(ARGV)
16
21
 
17
- Elelem::Server.serve(provider: provider, port: port)
22
+ Elelem::Server.serve(provider: provider, port: port, address: address)
@@ -0,0 +1,176 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
+ <title>elelem</title>
7
+ <style>
8
+ :root { color-scheme: dark; }
9
+ * { box-sizing: border-box; }
10
+ body {
11
+ margin: 0; height: 100dvh; display: flex; flex-direction: column;
12
+ background: #16161a; color: #e6e6e6;
13
+ font: 16px/1.55 ui-sans-serif, system-ui, -apple-system, sans-serif;
14
+ }
15
+ #log { flex: 1; overflow-y: auto; padding: 1rem; -webkit-overflow-scrolling: touch; }
16
+ .msg { max-width: 46rem; margin: 0 auto 1rem; padding: .65rem .85rem; border-radius: .6rem; }
17
+ .user { background: #2d3a4f; }
18
+ .assistant { background: #22222a; }
19
+ .thinking { opacity: .55; font-style: italic; white-space: pre-wrap; }
20
+ .msg pre {
21
+ background: #0d0d10; padding: .7rem; border-radius: .4rem;
22
+ overflow-x: auto; font-size: .875rem;
23
+ }
24
+ .msg code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
25
+ .prompt { max-width: 46rem; margin: 0 auto 1rem; padding: .85rem; border-radius: .6rem; background: #4a3a1e; }
26
+ .prompt button {
27
+ font-size: 1rem; padding: .7rem 1.4rem; margin: .5rem .5rem 0 0;
28
+ border: 0; border-radius: .5rem; color: #fff; touch-action: manipulation;
29
+ }
30
+ .allow { background: #2f7d4f; }
31
+ .deny { background: #8c3535; }
32
+ form { display: flex; gap: .5rem; padding: .75rem; padding-bottom: max(.75rem, env(safe-area-inset-bottom));
33
+ border-top: 1px solid #2c2c33; background: #1b1b20; }
34
+ textarea {
35
+ flex: 1; resize: none; font: inherit; padding: .6rem; border-radius: .5rem;
36
+ border: 1px solid #35353d; background: #101014; color: inherit;
37
+ }
38
+ button.send { font-size: 1rem; padding: .6rem 1.2rem; border: 0; border-radius: .5rem; background: #3b6ea5; color: #fff; }
39
+ button.send:disabled { opacity: .5; }
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <div id="log"></div>
44
+ <form id="composer">
45
+ <textarea id="input" rows="1" placeholder="Message elelem" autocapitalize="sentences"></textarea>
46
+ <button class="send" type="submit">Send</button>
47
+ </form>
48
+ <script>
49
+ const log = document.getElementById("log");
50
+ const input = document.getElementById("input");
51
+ let current = null;
52
+
53
+ const atBottom = () => log.scrollHeight - log.scrollTop - log.clientHeight < 80;
54
+ const scroll = (was) => { if (was) log.scrollTop = log.scrollHeight; };
55
+
56
+ // The token comes in the URL elelem-server prints. Keep it for reloads in this tab.
57
+ const token = new URLSearchParams(location.search).get("token") || sessionStorage.getItem("elelem-token") || "";
58
+ sessionStorage.setItem("elelem-token", token);
59
+ // Keep the token out of the address bar and history.
60
+ history.replaceState(null, "", "/");
61
+ // Each tab has its own conversation. A reload keeps it; a new tab starts fresh.
62
+ const random = () => (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2));
63
+ const tab = sessionStorage.getItem("elelem-tab") || random();
64
+ sessionStorage.setItem("elelem-tab", tab);
65
+
66
+ function post(path, body) {
67
+ const headers = { "Content-Type": "application/json", "X-Elelem-Token": token, "X-Elelem-Tab": tab };
68
+ return fetch(path, { method: "POST", headers: headers, body: JSON.stringify(body) });
69
+ }
70
+
71
+ function escape(text) {
72
+ return text.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
73
+ }
74
+
75
+ function render(text) {
76
+ return escape(text)
77
+ .replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => "<pre><code>" + code + "</code></pre>")
78
+ .replace(/`([^`\n]+)`/g, "<code>$1</code>")
79
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
80
+ .replace(/\n/g, "<br>");
81
+ }
82
+
83
+ function bubble(cls) {
84
+ const was = atBottom();
85
+ const el = document.createElement("div");
86
+ el.className = "msg " + cls;
87
+ log.appendChild(el);
88
+ scroll(was);
89
+ return el;
90
+ }
91
+
92
+ function append(cls, text) {
93
+ if (!current || current.dataset.cls !== cls) {
94
+ current = bubble(cls);
95
+ current.dataset.cls = cls;
96
+ current.dataset.raw = "";
97
+ }
98
+ const was = atBottom();
99
+ current.dataset.raw += text;
100
+ current.innerHTML = render(current.dataset.raw);
101
+ scroll(was);
102
+ }
103
+
104
+ function askPermission(id, text) {
105
+ // A reconnect replays open prompts; show each one once.
106
+ if (document.querySelector('.prompt[data-id="' + id + '"]')) return;
107
+ current = null;
108
+ const was = atBottom();
109
+ const el = document.createElement("div");
110
+ el.className = "prompt";
111
+ el.dataset.id = id;
112
+ el.innerHTML = "<div>" + escape(text) + "</div>";
113
+ const respond = (answer) => {
114
+ el.querySelectorAll("button").forEach((b) => b.remove());
115
+ el.insertAdjacentHTML("beforeend", "<div><em>" + answer + "</em></div>");
116
+ post("/answer", { id: el.dataset.id, text: answer });
117
+ };
118
+ const allow = document.createElement("button");
119
+ allow.className = "allow";
120
+ allow.textContent = "Allow";
121
+ allow.onclick = () => respond("y");
122
+ const deny = document.createElement("button");
123
+ deny.className = "deny";
124
+ deny.textContent = "Deny";
125
+ deny.onclick = () => respond("n");
126
+ el.append(allow, deny);
127
+ log.appendChild(el);
128
+ scroll(was);
129
+ }
130
+
131
+ new EventSource("/events?token=" + encodeURIComponent(token) + "&tab=" + encodeURIComponent(tab)).onmessage = (e) => {
132
+ const event = JSON.parse(e.data);
133
+ if (event.type === "content") append("assistant", event.text);
134
+ else if (event.type === "thinking") append("thinking", event.text);
135
+ else if (event.type === "prompt") askPermission(event.id, event.text);
136
+ else if (event.type === "done") { current = null; idle(); }
137
+ };
138
+
139
+ const send = document.querySelector("#composer .send");
140
+ const idle = () => { send.disabled = false; };
141
+
142
+ document.getElementById("composer").onsubmit = async (e) => {
143
+ e.preventDefault();
144
+ const text = input.value.trim();
145
+ if (!text || send.disabled) return;
146
+ send.disabled = true;
147
+ append("user", text);
148
+ current = null;
149
+ input.value = "";
150
+ try {
151
+ const res = await post("/message", { text: text });
152
+ if (res.ok) return;
153
+ append("thinking", res.status === 409 ? "Still working on the last message. Try again when it is done." : "Not sent: " + res.status);
154
+ current = null;
155
+ input.value = text;
156
+ idle();
157
+ } catch (_err) {
158
+ input.value = text;
159
+ idle();
160
+ }
161
+ };
162
+
163
+ input.addEventListener("keydown", (e) => {
164
+ if (e.key === "Enter" && !e.shiftKey) {
165
+ e.preventDefault();
166
+ document.getElementById("composer").requestSubmit();
167
+ }
168
+ });
169
+
170
+ input.addEventListener("input", () => {
171
+ input.style.height = "auto";
172
+ input.style.height = Math.min(input.scrollHeight, 160) + "px";
173
+ });
174
+ </script>
175
+ </body>
176
+ </html>
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class Server
5
+ # One browser tab: its session, and whether a turn is running in it.
6
+ class Tab
7
+ attr_reader :session
8
+
9
+ def initialize(session)
10
+ @session = session
11
+ @lock = Mutex.new
12
+ @busy = false
13
+ end
14
+
15
+ def terminal
16
+ @session.output
17
+ end
18
+
19
+ def claim
20
+ @lock.synchronize { @busy ? false : (@busy = true) }
21
+ end
22
+
23
+ # Busy tabs, and tabs with a page open or seen in the last minute (an
24
+ # EventSource that is reconnecting), keep their place.
25
+ def idle?(now = Process.clock_gettime(Process::CLOCK_MONOTONIC), grace: 60)
26
+ !@lock.synchronize { @busy } && terminal.subscribers.zero? && now - terminal.last_seen > grace
27
+ end
28
+
29
+ # Runs a message or a /command, then tells the page the turn is done.
30
+ def run(text)
31
+ text.start_with?("/") ? command(text) : @session.turn(text)
32
+ rescue StandardError, ScriptError => e
33
+ terminal.say("Error: #{e.message}")
34
+ ensure
35
+ # done first, so the page cannot start a turn before this one says it ended.
36
+ terminal.done
37
+ @lock.synchronize { @busy = false }
38
+ end
39
+
40
+ private
41
+
42
+ def command(line)
43
+ name, args = line.delete_prefix("/").split(" ", 2)
44
+ # /exit would stop the server, and /reload would reload plugins under
45
+ # every other tab. Restart elelem-server instead.
46
+ if %w[exit reload].include?(name)
47
+ return terminal.say("/#{name} is not available in the browser. Restart elelem-server instead.")
48
+ end
49
+ return if @session.commands.run(name, args)
50
+
51
+ terminal.say(@session.commands.names.join(" "))
52
+ end
53
+ end
54
+ end
55
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Elelem
4
4
  class Server
5
- VERSION = "0.2.0"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
@@ -3,27 +3,60 @@
3
3
  module Elelem
4
4
  class Server
5
5
  class WebTerminal
6
+ # Prompt ids are unique across every tab's terminal, so a page never
7
+ # mistakes a new prompt for one it already answered.
8
+ @next_id = 0
9
+ @ids = Mutex.new
10
+
11
+ def self.next_prompt_id
12
+ @ids.synchronize { @next_id += 1 }
13
+ end
14
+
15
+ attr_reader :last_seen
16
+
6
17
  def initialize
7
18
  @subscribers = []
8
19
  @mutex = Mutex.new
9
- @answers = Queue.new
20
+ @prompts = {}
21
+ @open = {}
22
+ @last_seen = Process.clock_gettime(Process::CLOCK_MONOTONIC)
10
23
  end
11
24
 
25
+ # A page that subscribes again, after a reload, gets the prompts still
26
+ # waiting for an answer, so the turn does not hang on one it cannot see.
12
27
  def subscribe
13
- Queue.new.tap { |queue| @mutex.synchronize { @subscribers << queue } }
28
+ Queue.new.tap do |queue|
29
+ @mutex.synchronize do
30
+ @subscribers << queue
31
+ @open.each_value { |event| queue << event }
32
+ end
33
+ end
34
+ end
35
+
36
+ def subscribers
37
+ @mutex.synchronize { @subscribers.size }
14
38
  end
15
39
 
16
40
  def unsubscribe(queue)
17
- @mutex.synchronize { @subscribers.delete(queue) }
41
+ @mutex.synchronize do
42
+ @subscribers.delete(queue)
43
+ @last_seen = Process.clock_gettime(Process::CLOCK_MONOTONIC)
44
+ end
18
45
  end
19
46
 
20
47
  def interactive?
21
48
  true
22
49
  end
23
50
 
24
- def ask(prompt)
25
- emit("prompt", prompt)
26
- @answers.pop
51
+ def ask(prompt, **)
52
+ queue = Queue.new
53
+ event = @mutex.synchronize do
54
+ id = self.class.next_prompt_id
55
+ @prompts[id] = queue
56
+ @open[id] = { type: "prompt", text: plain(prompt), id: id }
57
+ end
58
+ broadcast(event)
59
+ queue.pop
27
60
  end
28
61
 
29
62
  def thinking(text)
@@ -39,8 +72,7 @@ module Elelem
39
72
  say "#{state} #{name}(#{args})"
40
73
  end
41
74
 
42
- def waiting
43
- end
75
+ def waiting; end
44
76
 
45
77
  def print(text)
46
78
  emit("content", text)
@@ -55,8 +87,14 @@ module Elelem
55
87
  say(fallback || path)
56
88
  end
57
89
 
58
- def answer(text)
59
- @answers << text
90
+ def answer(id, text)
91
+ @mutex.synchronize do
92
+ if @prompts.key?(id)
93
+ @prompts[id] << text
94
+ @prompts.delete(id)
95
+ @open.delete(id)
96
+ end
97
+ end
60
98
  end
61
99
 
62
100
  def done
@@ -66,10 +104,14 @@ module Elelem
66
104
  private
67
105
 
68
106
  def emit(type, text)
69
- plain = text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
107
+ plain = plain(text)
70
108
  return if plain.strip.empty?
71
109
 
72
- broadcast(type: type, text: plain)
110
+ broadcast({ type: type, text: plain })
111
+ end
112
+
113
+ def plain(text)
114
+ text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
73
115
  end
74
116
 
75
117
  def broadcast(event)
data/lib/elelem/server.rb CHANGED
@@ -2,26 +2,44 @@
2
2
 
3
3
  require "elelem"
4
4
  require "json"
5
+ require "openssl"
6
+ require "securerandom"
7
+ require "uri"
5
8
  require "webrick"
6
9
 
7
10
  require_relative "server/version"
8
11
  require_relative "server/web_terminal"
12
+ require_relative "server/tab"
9
13
 
10
14
  module Elelem
11
15
  class Server
12
- def self.serve(provider: "stub", port: 4567, toolbox: Toolbox.new)
13
- llm_provider = Config.build_provider(provider)
14
- web_terminal = WebTerminal.new
15
- agent = Agent.new(llm_provider, toolbox: toolbox, output: web_terminal, input: web_terminal)
16
- Config.apply(agent)
17
- new(agent, port: port).start
16
+ INDEX = File.read(File.expand_path("server/index.html", __dir__))
17
+ LOCAL_HOSTS = %w[localhost 127.0.0.1 [::1]].freeze
18
+
19
+ MAX_TABS = 16
20
+ TAB_ID = /\A[\w-]{1,64}\z/
21
+
22
+ # Each browser tab gets its own session, built on its first request.
23
+ def self.serve(provider: "demo", port: 4567, address: "127.0.0.1")
24
+ Config.build_provider(provider) # fail at start, not on the first page load
25
+ new(-> { session_for(provider) }, port: port, address: address).start
26
+ end
27
+
28
+ def self.session_for(provider)
29
+ terminal = WebTerminal.new
30
+ Config.build_session(provider, input: terminal, output: terminal)
18
31
  end
19
32
 
20
- def initialize(agent, port: 4567, address: "0.0.0.0")
21
- @agent = agent
33
+ attr_reader :token
34
+
35
+ def initialize(build_session, port: 4567, address: "127.0.0.1", token: SecureRandom.hex(16))
36
+ @build_session = build_session
22
37
  @port = port
23
38
  @address = address
24
- @terminal = agent.output
39
+ @token = token
40
+ @tabs = {}
41
+ @tabs_lock = Mutex.new
42
+ @build_lock = Mutex.new
25
43
  end
26
44
 
27
45
  def start
@@ -31,202 +49,163 @@ module Elelem
31
49
  Logger: WEBrick::Log.new(File::NULL),
32
50
  AccessLog: []
33
51
  )
34
- server.mount_proc("/", &method(:index))
35
- server.mount_proc("/events", &method(:events))
36
- server.mount_proc("/message", &method(:message))
37
- server.mount_proc("/answer", &method(:answer))
52
+ %i[index events message answer].each do |name|
53
+ server.mount_proc(name == :index ? "/" : "/#{name}", &guarded(name, token: name != :index))
54
+ end
38
55
 
39
56
  trap("INT") { server.shutdown }
40
- $stdout.puts "elelem v#{VERSION} listening on http://#{@address}:#{@port}"
57
+ $stdout.puts "elelem v#{VERSION} listening on http://#{url_host}:#{@port}/?token=#{@token}"
58
+ $stdout.flush # the URL carries the token; it must show even when stdout is a file
41
59
  server.start
42
60
  end
43
61
 
44
62
  private
45
63
 
64
+ # The agent can run commands, so only this machine's own pages may drive it.
65
+ # The token, printed at start, keeps out other users and programs on the
66
+ # machine; the page itself holds no secret, so it is served without one.
67
+ # A Host check stops DNS rebinding; JSON plus a matching Origin stops a
68
+ # cross-site form or fetch.
69
+ def guarded(name, token: true)
70
+ handler = method(name)
71
+ lambda do |req, res|
72
+ next handler.call(req, res) if allowed?(req, token: token)
73
+
74
+ res.status = 403
75
+ res.body = "forbidden"
76
+ end
77
+ end
78
+
79
+ def allowed?(req, token: true)
80
+ host = req["Host"].to_s
81
+ return false if token && !token?(req)
82
+ return false unless local_hosts.include?(host.sub(/:\d+\z/, ""))
83
+ return true unless req.request_method == "POST"
84
+
85
+ json?(req) && same_origin?(req["Origin"], host)
86
+ end
87
+
88
+ # A header from the page's fetch calls; the query string for the first page
89
+ # load and for EventSource, which cannot set headers.
90
+ def token?(req)
91
+ given = req["X-Elelem-Token"] || req.query["token"]
92
+ given.is_a?(String) && OpenSSL.secure_compare(given, @token)
93
+ end
94
+
95
+ def local_hosts
96
+ LOCAL_HOSTS + [url_host]
97
+ end
98
+
99
+ # URLs and Host headers put an IPv6 address in brackets.
100
+ def url_host
101
+ @address.include?(":") ? "[#{@address}]" : @address
102
+ end
103
+
104
+ def json?(req)
105
+ req["Content-Type"].to_s.split(";").first.to_s.strip.casecmp?("application/json")
106
+ end
107
+
108
+ def same_origin?(origin, host)
109
+ return true if origin.nil?
110
+
111
+ uri = URI(origin)
112
+ authority = uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}"
113
+ authority == host
114
+ rescue URI::InvalidURIError
115
+ false
116
+ end
117
+
46
118
  def index(_req, res)
119
+ res["Referrer-Policy"] = "no-referrer"
47
120
  res.content_type = "text/html; charset=utf-8"
48
121
  res.body = INDEX
49
122
  end
50
123
 
51
- def events(_req, res)
124
+ def events(req, res)
125
+ tab = tab_for(req) or return no_tab(res)
126
+
52
127
  res.content_type = "text/event-stream"
53
128
  res["Cache-Control"] = "no-cache"
54
129
  res.chunked = true
55
- res.body = ->(out) { stream(out) }
130
+ res.body = ->(out) { stream(tab.terminal, out) }
56
131
  end
57
132
 
58
- def stream(out)
59
- events = @terminal.subscribe
133
+ def stream(terminal, out)
134
+ events = terminal.subscribe
60
135
  loop do
61
- out.write("data: #{JSON.dump(events.pop)}\n\n")
136
+ # A ping every 15 seconds finds a closed tab, so its thread ends.
137
+ event = events.pop(timeout: 15)
138
+ out.write(event ? "data: #{JSON.dump(event)}\n\n" : ": ping\n\n")
62
139
  end
63
140
  rescue IOError, Errno::EPIPE, Errno::ECONNRESET
64
141
  nil
65
142
  ensure
66
- @terminal.unsubscribe(events)
143
+ terminal.unsubscribe(events)
67
144
  end
68
145
 
146
+ # One turn at a time per tab: two at once would interleave one conversation.
69
147
  def message(req, res)
148
+ tab = tab_for(req) or return no_tab(res)
70
149
  text = JSON.parse(req.body)["text"].to_s
71
- Thread.new do
72
- @agent.turn(text)
73
- rescue => e
74
- @terminal.say("Error: #{e.message}")
75
- ensure
76
- @terminal.done
77
- end
150
+ # Claim under the tabs lock, so the tab cannot be evicted in between.
151
+ return busy(res) unless @tabs_lock.synchronize { @tabs.value?(tab) && tab.claim }
152
+
153
+ Thread.new { tab.run(text) }
78
154
  res.status = 204
79
155
  end
80
156
 
157
+ def busy(res)
158
+ res.status = 409
159
+ res.body = "busy"
160
+ end
161
+
81
162
  def answer(req, res)
82
- @terminal.answer(JSON.parse(req.body)["text"].to_s)
163
+ tab = tab_for(req) or return no_tab(res)
164
+ body = JSON.parse(req.body)
165
+ tab.terminal.answer(body["id"].to_i, body["text"].to_s)
83
166
  res.status = 204
84
167
  end
85
168
 
86
- INDEX = <<~HTML
87
- <!doctype html>
88
- <html lang="en">
89
- <head>
90
- <meta charset="utf-8">
91
- <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
92
- <title>elelem</title>
93
- <style>
94
- :root { color-scheme: dark; }
95
- * { box-sizing: border-box; }
96
- body {
97
- margin: 0; height: 100dvh; display: flex; flex-direction: column;
98
- background: #16161a; color: #e6e6e6;
99
- font: 16px/1.55 ui-sans-serif, system-ui, -apple-system, sans-serif;
100
- }
101
- #log { flex: 1; overflow-y: auto; padding: 1rem; -webkit-overflow-scrolling: touch; }
102
- .msg { max-width: 46rem; margin: 0 auto 1rem; padding: .65rem .85rem; border-radius: .6rem; }
103
- .user { background: #2d3a4f; }
104
- .assistant { background: #22222a; }
105
- .thinking { opacity: .55; font-style: italic; white-space: pre-wrap; }
106
- .msg pre {
107
- background: #0d0d10; padding: .7rem; border-radius: .4rem;
108
- overflow-x: auto; font-size: .875rem;
109
- }
110
- .msg code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
111
- .prompt { max-width: 46rem; margin: 0 auto 1rem; padding: .85rem; border-radius: .6rem; background: #4a3a1e; }
112
- .prompt button {
113
- font-size: 1rem; padding: .7rem 1.4rem; margin: .5rem .5rem 0 0;
114
- border: 0; border-radius: .5rem; color: #fff; touch-action: manipulation;
115
- }
116
- .allow { background: #2f7d4f; }
117
- .deny { background: #8c3535; }
118
- form { display: flex; gap: .5rem; padding: .75rem; padding-bottom: max(.75rem, env(safe-area-inset-bottom));
119
- border-top: 1px solid #2c2c33; background: #1b1b20; }
120
- textarea {
121
- flex: 1; resize: none; font: inherit; padding: .6rem; border-radius: .5rem;
122
- border: 1px solid #35353d; background: #101014; color: inherit;
123
- }
124
- button.send { font-size: 1rem; padding: .6rem 1.2rem; border: 0; border-radius: .5rem; background: #3b6ea5; color: #fff; }
125
- </style>
126
- </head>
127
- <body>
128
- <div id="log"></div>
129
- <form id="composer">
130
- <textarea id="input" rows="1" placeholder="Message elelem…" autocapitalize="sentences"></textarea>
131
- <button class="send" type="submit">Send</button>
132
- </form>
133
- <script>
134
- const log = document.getElementById("log");
135
- const input = document.getElementById("input");
136
- let current = null;
137
-
138
- const atBottom = () => log.scrollHeight - log.scrollTop - log.clientHeight < 80;
139
- const scroll = (was) => { if (was) log.scrollTop = log.scrollHeight; };
140
-
141
- function escape(text) {
142
- return text.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
143
- }
144
-
145
- function render(text) {
146
- return escape(text)
147
- .replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (_m, _lang, code) => "<pre><code>" + code + "</code></pre>")
148
- .replace(/`([^`\\n]+)`/g, "<code>$1</code>")
149
- .replace(/\\*\\*([^*]+)\\*\\*/g, "<strong>$1</strong>")
150
- .replace(/\\n/g, "<br>");
151
- }
152
-
153
- function bubble(cls) {
154
- const was = atBottom();
155
- const el = document.createElement("div");
156
- el.className = "msg " + cls;
157
- log.appendChild(el);
158
- scroll(was);
159
- return el;
160
- }
161
-
162
- function append(cls, text) {
163
- if (!current || current.dataset.cls !== cls) {
164
- current = bubble(cls);
165
- current.dataset.cls = cls;
166
- current.dataset.raw = "";
167
- }
168
- const was = atBottom();
169
- current.dataset.raw += text;
170
- current.innerHTML = render(current.dataset.raw);
171
- scroll(was);
172
- }
173
-
174
- function askPermission(text) {
175
- current = null;
176
- const was = atBottom();
177
- const el = document.createElement("div");
178
- el.className = "prompt";
179
- el.innerHTML = "<div>" + escape(text) + "</div>";
180
- const respond = (answer) => {
181
- el.querySelectorAll("button").forEach((b) => b.remove());
182
- el.insertAdjacentHTML("beforeend", "<div><em>" + answer + "</em></div>");
183
- fetch("/answer", { method: "POST", body: JSON.stringify({ text: answer }) });
184
- };
185
- const allow = document.createElement("button");
186
- allow.className = "allow";
187
- allow.textContent = "Allow";
188
- allow.onclick = () => respond("y");
189
- const deny = document.createElement("button");
190
- deny.className = "deny";
191
- deny.textContent = "Deny";
192
- deny.onclick = () => respond("n");
193
- el.append(allow, deny);
194
- log.appendChild(el);
195
- scroll(was);
196
- }
197
-
198
- new EventSource("/events").onmessage = (e) => {
199
- const event = JSON.parse(e.data);
200
- if (event.type === "content") append("assistant", event.text);
201
- else if (event.type === "thinking") append("thinking", event.text);
202
- else if (event.type === "prompt") askPermission(event.text);
203
- else if (event.type === "done") current = null;
204
- };
205
-
206
- document.getElementById("composer").onsubmit = (e) => {
207
- e.preventDefault();
208
- const text = input.value.trim();
209
- if (!text) return;
210
- append("user", text);
211
- current = null;
212
- input.value = "";
213
- fetch("/message", { method: "POST", body: JSON.stringify({ text: text }) });
214
- };
215
-
216
- input.addEventListener("keydown", (e) => {
217
- if (e.key === "Enter" && !e.shiftKey) {
218
- e.preventDefault();
219
- document.getElementById("composer").requestSubmit();
220
- }
221
- });
222
-
223
- input.addEventListener("input", () => {
224
- input.style.height = "auto";
225
- input.style.height = Math.min(input.scrollHeight, 160) + "px";
226
- });
227
- </script>
228
- </body>
229
- </html>
230
- HTML
169
+ # The page sends its tab id as a header, or in the query for EventSource.
170
+ def tab_for(req)
171
+ id = req["X-Elelem-Tab"] || req.query["tab"]
172
+ return unless id.is_a?(String) && id.match?(TAB_ID)
173
+
174
+ @tabs_lock.synchronize { @tabs[id] } || new_tab(id)
175
+ end
176
+
177
+ # Built outside @tabs_lock, so a slow provider does not hold up other
178
+ # tabs, and one at a time, because loading plugins is not thread-safe.
179
+ def new_tab(id)
180
+ @build_lock.synchronize do
181
+ existing = @tabs_lock.synchronize { @tabs[id] }
182
+ next existing if existing
183
+ next unless room? # no session is built only to be refused
184
+
185
+ add_tab(id, Tab.new(@build_session.call))
186
+ end
187
+ end
188
+
189
+ def room?
190
+ @tabs_lock.synchronize { @tabs.size < MAX_TABS || @tabs.each_value.any?(&:idle?) }
191
+ end
192
+
193
+ # At the limit, a tab that is idle with no page open gives up its place.
194
+ def add_tab(id, tab)
195
+ @tabs_lock.synchronize do
196
+ if @tabs.size >= MAX_TABS
197
+ idle = @tabs.find { |_, other| other.idle? }&.first
198
+ return unless idle
199
+
200
+ @tabs.delete(idle)
201
+ end
202
+ @tabs[id] = tab
203
+ end
204
+ end
205
+
206
+ def no_tab(res)
207
+ res.status = 400
208
+ res.body = "missing tab, or too many tabs (#{MAX_TABS})"
209
+ end
231
210
  end
232
211
  end
@@ -0,0 +1,12 @@
1
+ {
2
+ "bootstrap-sha": "2db5a1c81ce0983d7e4e64772169c0b6dc0b5277",
3
+ "packages": {
4
+ ".": {
5
+ "release-type": "ruby",
6
+ "package-name": "elelem-server",
7
+ "version-file": "lib/elelem/server/version.rb",
8
+ "bump-minor-pre-major": true,
9
+ "include-component-in-tag": false
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Elelem::Server, "request guard" do
4
+ subject(:server) { described_class.new(-> { session }, port: 4567, token: "secret") }
5
+
6
+ let(:session) { double("session", output: double("terminal")) }
7
+
8
+ def request(method: "GET", host: "127.0.0.1:4567", query: {}, x_elelem_token: "secret", **headers)
9
+ headers = headers.merge(x_elelem_token: x_elelem_token).compact
10
+ fields = { "Host" => host }.merge(headers.transform_keys { |k| k.to_s.split("_").map(&:capitalize).join("-") })
11
+ double("request", request_method: method, query: query, "[]": nil).tap do |req|
12
+ fields.each { |name, value| allow(req).to receive(:[]).with(name).and_return(value) }
13
+ end
14
+ end
15
+
16
+ def allowed?(req) = server.send(:allowed?, req)
17
+
18
+ it "refuses a request without the token" do
19
+ expect(allowed?(request(x_elelem_token: nil))).to be false
20
+ expect(allowed?(request(x_elelem_token: "guess"))).to be false
21
+ end
22
+
23
+ it "serves the page itself without the token, which holds no secret" do
24
+ expect(server.send(:allowed?, request(x_elelem_token: nil), token: false)).to be true
25
+ expect(server.send(:allowed?, request(x_elelem_token: nil, host: "evil.example"), token: false)).to be false
26
+ end
27
+
28
+ it "takes the token from the query string, for the first page load and EventSource" do
29
+ expect(allowed?(request(x_elelem_token: nil, query: { "token" => "secret" }))).to be true
30
+ end
31
+
32
+ it "makes a new token for each server" do
33
+ expect(described_class.new(-> { session }).token).not_to eq(described_class.new(-> { session }).token)
34
+ end
35
+
36
+ it "binds to 127.0.0.1 by default" do
37
+ expect(server.instance_variable_get(:@address)).to eq("127.0.0.1")
38
+ end
39
+
40
+ it "serves the page to localhost, 127.0.0.1 and [::1]" do
41
+ %w[localhost:4567 127.0.0.1:4567 [::1]:4567].each { |host| expect(allowed?(request(host: host))).to be true }
42
+ end
43
+
44
+ it "answers a bound IPv6 address, which a Host header puts in brackets" do
45
+ server = described_class.new(-> { session }, port: 4567, address: "2001:db8::5", token: "secret")
46
+
47
+ expect(server.send(:allowed?, request(host: "[2001:db8::5]:4567"))).to be true
48
+ end
49
+
50
+ it "refuses another Host, which is what DNS rebinding sends" do
51
+ expect(allowed?(request(host: "evil.example:4567"))).to be false
52
+ end
53
+
54
+ it "takes a JSON post from its own page" do
55
+ req = request(method: "POST", content_type: "application/json; charset=utf-8", origin: "http://127.0.0.1:4567")
56
+
57
+ expect(allowed?(req)).to be true
58
+ end
59
+
60
+ it "refuses a post that is not JSON, which a cross-site form can send" do
61
+ expect(allowed?(request(method: "POST", content_type: "text/plain"))).to be false
62
+ end
63
+
64
+ it "refuses a post from another origin" do
65
+ req = request(method: "POST", content_type: "application/json", origin: "https://evil.example")
66
+
67
+ expect(allowed?(req)).to be false
68
+ end
69
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe "the web page" do
4
+ let(:html) { Elelem::Server::INDEX }
5
+
6
+ # The page once lived in a Ruby heredoc, which needed \\n. In a .html file
7
+ # that is a literal backslash, so no reply rendered its line breaks.
8
+ it "has no regex escaped for a Ruby string" do
9
+ expect(html.scan(%r{\.replace\(/[^\n]*\\\\[nwsS*]})).to be_empty
10
+ end
11
+
12
+ it "turns line breaks into <br>" do
13
+ expect(html).to include(".replace(/\\n/g, \"<br>\")")
14
+ end
15
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Elelem::Server, ".session_for" do
4
+ it "builds a tab session with the demo provider" do
5
+ session = described_class.session_for("demo")
6
+
7
+ expect(session.turn("echo hi")).to start_with("exit 0")
8
+ end
9
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Elelem::Server::Tab do
4
+ subject(:tab) { described_class.new(session) }
5
+
6
+ let(:said) { [] }
7
+ let(:terminal) { double("terminal", done: nil).tap { |t| allow(t).to receive(:say) { |text| said << text } } }
8
+ let(:commands) { Elelem::Commands.new.tap { |c| c.register("ping", description: "Ping") { said << "pong" } } }
9
+ let(:session) { double("session", output: terminal, commands: commands, turn: nil) }
10
+
11
+ it "runs a slash command instead of sending it to the model" do
12
+ tab.run("/ping")
13
+
14
+ expect(said).to eq(["pong"])
15
+ expect(session).not_to have_received(:turn)
16
+ end
17
+
18
+ it "lists the commands for an unknown one" do
19
+ tab.run("/nope")
20
+
21
+ expect(said).to eq(["/ping"])
22
+ end
23
+
24
+ it "does not let /exit or /reload reach past this tab" do
25
+ tab.run("/exit")
26
+ tab.run("/reload")
27
+
28
+ expect(said).to all(match(/not available in the browser/))
29
+ end
30
+
31
+ it "sends anything else to the model, and says when the turn is done" do
32
+ tab.run("hello")
33
+
34
+ expect(session).to have_received(:turn).with("hello")
35
+ expect(terminal).to have_received(:done)
36
+ end
37
+
38
+ it "keeps its place for a minute after its page goes, so a reconnecting page finds it" do
39
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
40
+ allow(terminal).to receive_messages(subscribers: 0, last_seen: now)
41
+
42
+ expect(tab.idle?(now + 30)).to be false
43
+ expect(tab.idle?(now + 61)).to be true
44
+ end
45
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Elelem::Server, "tabs and turns" do
4
+ subject(:server) { described_class.new(-> { build.call }, token: "t") }
5
+
6
+ let(:gate) { Queue.new }
7
+ let(:sessions) { [] }
8
+ let(:build) do
9
+ lambda do
10
+ terminal = double("terminal", done: nil, say: nil, subscribers: 0, last_seen: -1_000_000)
11
+ double("session", output: terminal).tap do |s|
12
+ allow(s).to receive(:turn) { gate.pop }
13
+ sessions << s
14
+ end
15
+ end
16
+ end
17
+
18
+ def request(tab, text = "hi")
19
+ double("request", body: JSON.generate(text: text), query: {}).tap do |req|
20
+ allow(req).to receive(:[]).with("X-Elelem-Tab").and_return(tab)
21
+ end
22
+ end
23
+
24
+ def post(tab, text = "hi")
25
+ res = Struct.new(:status, :body).new
26
+ server.send(:message, request(tab, text), res)
27
+ res
28
+ end
29
+
30
+ def wait_idle(tab)
31
+ sleep 0.01 until server.send(:tab_for, request(tab)).claim
32
+ server.send(:tab_for, request(tab)).instance_variable_set(:@busy, false)
33
+ end
34
+
35
+ it "gives each tab its own session" do
36
+ post("a")
37
+ post("b")
38
+
39
+ expect(sessions.size).to eq(2)
40
+ 2.times { gate << :finish }
41
+ end
42
+
43
+ it "refuses a second message in the same tab while its turn runs, then takes the next" do
44
+ expect(post("a").status).to eq(204)
45
+ expect(post("a").status).to eq(409)
46
+ expect(post("b").status).to eq(204)
47
+
48
+ 2.times { gate << :finish }
49
+ wait_idle("a")
50
+ expect(post("a").status).to eq(204)
51
+ gate << :finish
52
+ end
53
+
54
+ it "refuses a request without a tab id" do
55
+ expect(post(nil).status).to eq(400)
56
+ end
57
+
58
+ it "frees an idle tab's place at the limit, but not a busy one's" do
59
+ described_class::MAX_TABS.times { |i| post("t#{i}") }
60
+ expect(post("one-more").status).to eq(400)
61
+
62
+ gate << :finish
63
+ sleep 0.01 until server.instance_variable_get(:@tabs).values.any?(&:idle?)
64
+ expect(post("one-more").status).to eq(204)
65
+ described_class::MAX_TABS.times { gate << :finish }
66
+ end
67
+
68
+ it "builds a new tab without holding up the others" do
69
+ post("a")
70
+ gate << :finish
71
+ slow = Queue.new
72
+ server.instance_variable_set(:@build_session, -> { slow.pop and build.call })
73
+ Thread.new { server.send(:tab_for, request("slow")) }
74
+ sleep 0.05
75
+
76
+ expect(Timeout.timeout(1) { server.send(:tab_for, request("a")) }).not_to be_nil
77
+ slow << :go
78
+ end
79
+ end
@@ -101,11 +101,61 @@ RSpec.describe Elelem::Server::WebTerminal do
101
101
  end
102
102
 
103
103
  describe "#ask" do
104
- it "emits a prompt and blocks until answered" do
104
+ it "emits a prompt with an id and blocks until answered with a matching id" do
105
105
  thread = Thread.new { subject.ask("Allow?") }
106
- expect(events.pop).to eq({ type: "prompt", text: "Allow?" })
107
- subject.answer("n")
106
+ event = events.pop
107
+ expect(event).to include(type: "prompt", text: "Allow?")
108
+ subject.answer(event[:id], "n")
108
109
  expect(thread.value).to eq("n")
109
110
  end
111
+
112
+ it "ignores an answer for an id that isn't pending" do
113
+ thread = Thread.new { subject.ask("Allow?") }
114
+ event = events.pop
115
+ subject.answer(event[:id] + 1, "n")
116
+ subject.answer(event[:id], "y")
117
+ expect(thread.value).to eq("y")
118
+ end
119
+
120
+ it "does not let an answer sent before any prompt pre-approve the next prompt" do
121
+ subject.answer(1, "y")
122
+
123
+ thread = Thread.new { subject.ask("Allow?") }
124
+ event = events.pop
125
+ subject.answer(event[:id], "n")
126
+
127
+ expect(thread.value).to eq("n")
128
+ end
129
+
130
+ it "accepts the options a session passes to ask" do
131
+ thread = Thread.new { subject.ask("Allow?", history: false) }
132
+ subject.answer(events.pop[:id], "y")
133
+ expect(thread.value).to eq("y")
134
+ end
135
+ end
136
+
137
+ it "replays a prompt still waiting for an answer to a page that subscribes again" do
138
+ terminal = described_class.new
139
+ asking = Thread.new { terminal.ask("Allow execute `ls`? [Y/n] > ") }
140
+ sleep 0.01 until terminal.instance_variable_get(:@open).any?
141
+
142
+ replay = terminal.subscribe.pop(timeout: 1)
143
+ expect(replay).to include(type: "prompt", text: "Allow execute `ls`? [Y/n] > ")
144
+
145
+ terminal.answer(replay[:id], "y")
146
+ expect(asking.value).to eq("y")
147
+ expect(terminal.subscribe.pop(timeout: 0.05)).to be_nil
148
+ end
149
+
150
+ it "numbers prompts across every terminal, so a rebuilt tab never reuses an id" do
151
+ first = described_class.new
152
+ second = described_class.new
153
+ ids = [first, second].map do |terminal|
154
+ Thread.new { terminal.ask("ok?") }
155
+ sleep 0.01 until terminal.instance_variable_get(:@open).any?
156
+ terminal.instance_variable_get(:@open).keys.first.tap { |id| terminal.answer(id, "y") }
157
+ end
158
+
159
+ expect(ids.uniq.size).to eq(2)
110
160
  end
111
161
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: elelem-server
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - mo khan
@@ -13,16 +13,22 @@ dependencies:
13
13
  name: elelem
14
14
  requirement: !ruby/object:Gem::Requirement
15
15
  requirements:
16
- - - "~>"
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 0.13.1
19
+ - - "<"
17
20
  - !ruby/object:Gem::Version
18
- version: '0.11'
21
+ version: '1'
19
22
  type: :runtime
20
23
  prerelease: false
21
24
  version_requirements: !ruby/object:Gem::Requirement
22
25
  requirements:
23
- - - "~>"
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: 0.13.1
29
+ - - "<"
24
30
  - !ruby/object:Gem::Version
25
- version: '0.11'
31
+ version: '1'
26
32
  - !ruby/object:Gem::Dependency
27
33
  name: json
28
34
  requirement: !ruby/object:Gem::Requirement
@@ -59,22 +65,35 @@ executables:
59
65
  extensions: []
60
66
  extra_rdoc_files: []
61
67
  files:
68
+ - ".github/workflows/release.yml"
69
+ - ".release-please-manifest.json"
62
70
  - ".rspec"
71
+ - ".rubocop.yml"
72
+ - CHANGELOG.md
63
73
  - LICENSE.txt
64
74
  - Rakefile
65
75
  - exe/elelem-server
66
76
  - lib/elelem/server.rb
77
+ - lib/elelem/server/index.html
78
+ - lib/elelem/server/tab.rb
67
79
  - lib/elelem/server/version.rb
68
80
  - lib/elelem/server/web_terminal.rb
81
+ - release-please-config.json
82
+ - spec/elelem/server/guard_spec.rb
83
+ - spec/elelem/server/index_spec.rb
84
+ - spec/elelem/server/serve_spec.rb
85
+ - spec/elelem/server/tab_spec.rb
86
+ - spec/elelem/server/turn_spec.rb
69
87
  - spec/elelem/server/web_terminal_spec.rb
70
88
  - spec/spec_helper.rb
71
- homepage: https://src.mokhan.ca/elelem/server
89
+ homepage: https://elelem.sh
72
90
  licenses:
73
91
  - MIT
74
92
  metadata:
75
93
  allowed_push_host: https://rubygems.org
76
- homepage_uri: https://src.mokhan.ca/elelem/server
77
- source_code_uri: https://src.mokhan.ca/elelem/server
94
+ homepage_uri: https://elelem.sh
95
+ source_code_uri: https://github.com/elelem-sh/server
96
+ rubygems_mfa_required: 'true'
78
97
  rdoc_options: []
79
98
  require_paths:
80
99
  - lib
@@ -89,7 +108,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
89
108
  - !ruby/object:Gem::Version
90
109
  version: '0'
91
110
  requirements: []
92
- rubygems_version: 4.0.20
111
+ rubygems_version: 4.0.6
93
112
  specification_version: 4
94
113
  summary: A web UI server plugin for elelem.
95
114
  test_files: []