samagotchi 0.122.0 → 0.124.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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/bin/chi +13 -2
  4. data/docs/architecture.md +36 -7
  5. data/docs/cli.md +48 -3
  6. data/docs/configuration.md +31 -6
  7. data/docs/desktop.md +51 -3
  8. data/docs/guardrails.md +112 -15
  9. data/docs/internals/engine.md +11 -4
  10. data/docs/internals/participants.md +67 -17
  11. data/docs/internals/prompt-caching.md +19 -7
  12. data/docs/internals/web-push.md +7 -3
  13. data/docs/sessions.md +7 -3
  14. data/docs/testing.md +2 -1
  15. data/lib/samagotchi/bridge.rb +4 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/config.rb +7 -1
  18. data/lib/samagotchi/delivery.rb +7 -0
  19. data/lib/samagotchi/desktop/macos/App.swift +9 -1
  20. data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
  21. data/lib/samagotchi/desktop/macos/Images.swift +4 -3
  22. data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
  23. data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
  24. data/lib/samagotchi/engine.rb +200 -29
  25. data/lib/samagotchi/gem_update.rb +41 -4
  26. data/lib/samagotchi/guardrail_wiring.rb +45 -12
  27. data/lib/samagotchi/guardrails/approval.rb +42 -4
  28. data/lib/samagotchi/guardrails/gate.rb +1 -1
  29. data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
  30. data/lib/samagotchi/guardrails/pair_command.rb +365 -0
  31. data/lib/samagotchi/guardrails/review_paths.rb +41 -7
  32. data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
  33. data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
  34. data/lib/samagotchi/guardrails/verdict.rb +7 -0
  35. data/lib/samagotchi/guardrails.rb +1 -0
  36. data/lib/samagotchi/guardrails_command.rb +6 -1
  37. data/lib/samagotchi/kernel_loop.rb +20 -0
  38. data/lib/samagotchi/llm/chat_loop.rb +4 -0
  39. data/lib/samagotchi/participant_mode.rb +77 -3
  40. data/lib/samagotchi/participants.rb +7 -5
  41. data/lib/samagotchi/review_apply.rb +10 -2
  42. data/lib/samagotchi/review_start.rb +38 -10
  43. data/lib/samagotchi/run_options.rb +43 -2
  44. data/lib/samagotchi/session_access/local.rb +5 -2
  45. data/lib/samagotchi/session_manager.rb +11 -0
  46. data/lib/samagotchi/session_participants.rb +18 -3
  47. data/lib/samagotchi/share_command.rb +2 -2
  48. data/lib/samagotchi/steer.rb +7 -1
  49. data/lib/samagotchi/system_one/client.rb +10 -2
  50. data/lib/samagotchi/system_prompt.rb +20 -4
  51. data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
  52. data/lib/samagotchi/tool_runner.rb +10 -1
  53. data/lib/samagotchi/toolkit_binding.rb +34 -0
  54. data/lib/samagotchi/toolkit_report_command.rb +6 -4
  55. data/lib/samagotchi/toolkit_suggest.rb +181 -15
  56. data/lib/samagotchi/tools/builtins.rb +14 -1
  57. data/lib/samagotchi/update_command.rb +47 -19
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/app.rb +204 -9
  60. data/lib/samagotchi/web/grants.rb +17 -5
  61. data/lib/samagotchi/web/message_display.rb +6 -1
  62. data/lib/samagotchi/web/participant_review.rb +3 -1
  63. data/lib/samagotchi/web/participant_view.rb +12 -4
  64. data/lib/samagotchi/web/participants_admin.rb +10 -6
  65. data/lib/samagotchi/web/public/app.js +73 -10
  66. data/lib/samagotchi/web/public/data.js +17 -0
  67. data/lib/samagotchi/web/public/delivery_line.js +8 -6
  68. data/lib/samagotchi/web/public/index.html +21 -2
  69. data/lib/samagotchi/web/public/participant.html +1 -1
  70. data/lib/samagotchi/web/public/participant.js +10 -12
  71. data/lib/samagotchi/web/public/participant_model.js +20 -8
  72. data/lib/samagotchi/web/public/participant_names.js +29 -8
  73. data/lib/samagotchi/web/public/participants_panel.js +17 -3
  74. data/lib/samagotchi/web/public/question_card.js +12 -2
  75. data/lib/samagotchi/web/public/question_cards.js +7 -0
  76. data/lib/samagotchi/web/public/stream_handlers.js +6 -2
  77. data/lib/samagotchi/web/public/toasts.js +13 -5
  78. data/lib/samagotchi/web/public/tools_overview.js +42 -0
  79. data/lib/samagotchi/web/public/turn_events.js +5 -4
  80. data/lib/samagotchi/web/public/turn_html.js +2 -2
  81. data/lib/samagotchi/web/public/turn_view.js +6 -4
  82. data/lib/samagotchi/web/public/update_notice.js +33 -4
  83. data/lib/samagotchi/web/public/update_sheet.js +158 -0
  84. data/lib/samagotchi/web/push/notifier.rb +28 -1
  85. data/lib/samagotchi/web/server.rb +121 -8
  86. data/lib/samagotchi/web/session_access_routes.rb +2 -1
  87. data/lib/samagotchi/web/session_hub.rb +52 -3
  88. data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
  89. data/lib/samagotchi/web/update_check.rb +186 -0
  90. data/lib/samagotchi/worker.rb +44 -6
  91. metadata +7 -1
@@ -6,6 +6,17 @@
6
6
  // changes).
7
7
 
8
8
  const known = (v) => typeof v === "string" && v !== "";
9
+ const has = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
10
+
11
+ // Merge partial chi frames without treating an omitted field as a cleared value.
12
+ export function mergeVersionState(current = {}, frame = {}) {
13
+ const next = { ...current };
14
+ if (known(frame.version)) next.served = frame.version;
15
+ if (has(frame, "installed")) next.installed = known(frame.installed) ? frame.installed : null;
16
+ if (has(frame, "available")) next.available = known(frame.available) ? frame.available : null;
17
+ if (has(frame, "update_blocked")) next.updateBlocked = known(frame.update_blocked) ? frame.update_blocked : null;
18
+ return next;
19
+ }
9
20
 
10
21
  // Gem::Version's order, near enough: segments of digits or letters, a
11
22
  // missing one counts as 0, and letters (a prerelease: 0.19.0.pre1) sort
@@ -34,19 +45,37 @@ export function newerVersion(a, b) {
34
45
  // The toast for the versions as the page knows them, or null:
35
46
  // - loaded ≠ served: chi web restarted on another version; reload the page;
36
47
  // - installed newer than served: chi web runs an older chi than installed;
37
- // only its terminal can restart it.
48
+ // restart its workers or chi web;
49
+ // - available newer than served: install it, unless the install is blocked.
38
50
  // Each has a key; one in +shown+ (a Set: what this tab already showed, as
39
51
  // every reconnect's snapshot says it again) is not shown twice.
40
52
  // @return {{key, text, reload: boolean} | null}
41
- export function versionNotice({ loaded, served, installed, shown = new Set() } = {}) {
53
+ export function versionNotice({ loaded, served, installed, available, updateBlocked, shown = new Set() } = {}) {
42
54
  let notice = null;
43
55
  if (known(loaded) && known(served) && loaded !== served) {
44
56
  notice = { key: `reload:${served}`, text: `chi was updated to ${served}`, reload: true };
45
57
  } else if (newerVersion(installed, served)) {
46
58
  notice = {
47
59
  key: `installed:${installed}`,
48
- text: `chi ${installed} is installed; this chi web runs ${served}. ` +
49
- "Restart it: Ctrl-C in its terminal, then chi web",
60
+ text: `chi ${installed} is installed; this chi web runs ${served}.`,
61
+ actions: ["restartWorkers", "restartWeb"],
62
+ reload: false,
63
+ };
64
+ } else if (newerVersion(available, served)) {
65
+ const commands = {
66
+ checkout: ["this chi is running from a checkout", "git pull"],
67
+ bundler: ["chi update cannot run under Bundler", "bundle update samagotchi"],
68
+ gem_dir_not_writable: ["the RubyGems directory is not writable", `gem install samagotchi -v ${available}`],
69
+ };
70
+ const blocked = commands[updateBlocked];
71
+ const command = blocked?.[1] || "chi update";
72
+ notice = {
73
+ key: `available:${available}`,
74
+ text: blocked
75
+ ? `chi ${available} is available (you run ${served}); ${blocked[0]}. Run ${command}.`
76
+ : `chi ${available} is available (you run ${served}).`,
77
+ ...(blocked ? { command } : { updateVersion: available }),
78
+ releaseUrl: `https://rubygems.org/gems/samagotchi/versions/${encodeURIComponent(available)}`,
50
79
  reload: false,
51
80
  };
52
81
  }
@@ -0,0 +1,158 @@
1
+ import { html, nothing, render } from "./vendor/lit-html.js";
2
+
3
+ function installRow(row) {
4
+ const kept = Array.isArray(row.kept) ? row.kept.map(String) : [];
5
+ const versions = [row.from, row.to].filter(Boolean).join(" → ");
6
+ return html`<li class="us-row">
7
+ <div class="us-row-head"><strong>${row.component || "chi update"}</strong><span>${row.status || "working"}</span></div>
8
+ ${versions ? html`<div class="us-detail">${versions}</div>` : nothing}
9
+ ${kept.length ? html`<div class="us-kept"><strong>Kept edited files:</strong><ul>${kept.map((file) => html`<li>${file}</li>`)}</ul></div>` : nothing}
10
+ </li>`;
11
+ }
12
+
13
+ function workerRow(row) {
14
+ const sessionId = String(row.session_id || "unknown");
15
+ const detail = row.detail || row.reason;
16
+ const holds = Array.isArray(row.holds) ? row.holds.map((hold) => String(hold).replaceAll("_", " ")).join(", ") : "";
17
+ const version = row.version ? `chi ${row.version}` : "";
18
+ return html`<li class="us-row">
19
+ <div class="us-row-head"><strong title=${sessionId}>${sessionId.slice(0, 8)}</strong>
20
+ <span>${String(row.state || "working").replaceAll("_", " ")}</span></div>
21
+ ${version ? html`<div class="us-detail">${version}</div>` : nothing}
22
+ ${detail ? html`<div class="us-detail">${detail}</div>` : nothing}
23
+ ${holds ? html`<div class="us-detail">Holds: ${holds}</div>` : nothing}
24
+ </li>`;
25
+ }
26
+
27
+ function operationTitle(model) {
28
+ if (model.operation === "install") return `Update chi${model.version ? ` to ${model.version}` : ""}`;
29
+ if (model.operation === "workers") return "Restart workers";
30
+ return "Restart chi web";
31
+ }
32
+
33
+ // A running operation's last progress line; the end's own words after it
34
+ // (a done frame carries no progress, and the last line would stay).
35
+ function statusText(model) {
36
+ if (model.error) return model.error;
37
+ if (model.operation === "web" && model.state === "restarting") return "Waiting for chi web to reconnect…";
38
+ if (model.operation === "web" && model.state === "done") return `Reconnected${model.version ? ` on chi ${model.version}` : ""}.`;
39
+ if (model.state === "starting") return "Starting…";
40
+ if (model.state === "running") return model.progress || (model.operation === "install" ? "Installing…" : "Restarting…");
41
+ if (model.state === "done") return model.operation === "install" ? "Update complete." : "Worker restart results.";
42
+ return "Operation failed.";
43
+ }
44
+
45
+ // Both restarts, once a newer chi is installed (an install done here, or the
46
+ // installed notice's toast) and nothing runs: either result keeps the other in reach.
47
+ function restartActions(model) {
48
+ const busy = ["starting", "running", "restarting"].includes(model.state);
49
+ if (!model.restartable || busy) return nothing;
50
+ return html`<button type="button" class="ghost" data-act="restart-workers">Restart workers</button>
51
+ <button type="button" class="ghost" data-act="restart-web">Restart chi web</button>`;
52
+ }
53
+
54
+ export function updateSheetTemplate(model) {
55
+ const rows = Array.isArray(model.rows) ? model.rows : [];
56
+ const rowTemplate = model.operation === "workers" ? workerRow : installRow;
57
+ return html`<header class="us-head">
58
+ <h2 id="updateSheetTitle">${operationTitle(model)}</h2>
59
+ <button type="button" class="us-close" data-act="close" aria-label="Close">×</button>
60
+ </header>
61
+ <p class="us-status" aria-live="polite">${statusText(model)}</p>
62
+ ${rows.length ? html`<ol class="us-rows">${rows.map(rowTemplate)}</ol>` : nothing}
63
+ <footer class="us-foot">${restartActions(model)}<button type="button" class="ghost" data-act="close">Close</button></footer>`;
64
+ }
65
+
66
+ // The update progress/result sheet; operations continue if it is dismissed.
67
+ export function createUpdateSheet({ dialog, update, restartWorkers, restartWeb }) {
68
+ let model = null;
69
+ let requestId = 0;
70
+ let restartable = false;
71
+
72
+ function draw() {
73
+ if (model) render(updateSheetTemplate({ ...model, restartable }), dialog);
74
+ }
75
+
76
+ function show(next) {
77
+ model = { ...next, requestId: ++requestId };
78
+ draw();
79
+ if (!dialog.open) dialog.showModal();
80
+ }
81
+
82
+ function fail(error, expectedId) {
83
+ if (!model || model.requestId !== expectedId) return;
84
+ model = { ...model, state: "failed", error: error?.message || String(error) };
85
+ draw();
86
+ }
87
+
88
+ function request(action) {
89
+ const expectedId = model.requestId;
90
+ Promise.resolve().then(action).catch((error) => fail(error, expectedId));
91
+ }
92
+
93
+ dialog.addEventListener("click", (event) => {
94
+ const act = event.target.closest?.("[data-act]")?.dataset.act;
95
+ if (act === "close") dialog.close();
96
+ else if (act === "restart-workers") openWorkers();
97
+ else if (act === "restart-web") openWeb();
98
+ });
99
+
100
+ function openInstall(version) {
101
+ show({ operation: "install", version, state: "starting", rows: [] });
102
+ request(() => update(version));
103
+ }
104
+
105
+ function openWorkers() {
106
+ restartable = true;
107
+ show({ operation: "workers", state: "starting", rows: [] });
108
+ request(restartWorkers);
109
+ }
110
+
111
+ function openWeb() {
112
+ restartable = true;
113
+ show({ operation: "web", state: "restarting", rows: [] });
114
+ request(restartWeb);
115
+ }
116
+
117
+ function receive(data) {
118
+ if (!["install", "workers"].includes(data?.operation)) return false;
119
+ if (model?.operation === "web" && model.state === "restarting") return false;
120
+ if (!model || (model.operation !== data.operation && ["done", "failed"].includes(model.state))) {
121
+ model = { operation: data.operation, state: data.state, rows: [], version: data.version };
122
+ }
123
+ if (model.operation !== data.operation) return false;
124
+ model = {
125
+ ...model,
126
+ ...data,
127
+ rows: Array.isArray(data.rows) ? data.rows : [],
128
+ };
129
+ if (data.operation === "install" && data.state === "done") restartable = true;
130
+ draw();
131
+ return true;
132
+ }
133
+
134
+ function reconnected(version) {
135
+ if (model?.operation !== "web" || model.state !== "restarting") return false;
136
+ model = { ...model, state: "done", version };
137
+ draw();
138
+ return true;
139
+ }
140
+
141
+ function view() {
142
+ if (!model) return false;
143
+ draw();
144
+ if (!dialog.open) dialog.showModal();
145
+ return true;
146
+ }
147
+
148
+ return {
149
+ install: openInstall,
150
+ restartWorkers: openWorkers,
151
+ restartWeb: openWeb,
152
+ receive,
153
+ reconnected,
154
+ view,
155
+ close: () => dialog.close(),
156
+ isOpen: () => dialog.open,
157
+ };
158
+ }
@@ -45,6 +45,8 @@ module Samagotchi
45
45
  SHOW_TTL = 12 * 3600
46
46
  # A failed or done turn: stale soon, no need to wake the phone.
47
47
  EVENT_TTL = 600
48
+ # A newly available chi is useful for a day while the phone is away.
49
+ UPDATE_TTL = 24 * 3600
48
50
  # A show carries +open+ only while its JSON stays this small: one
49
51
  # aes128gcm record leaves 3993 bytes of plaintext under Web Push's
50
52
  # 4096 (86 header, 1 delimiter, 16 tag). Longer, it goes without
@@ -96,6 +98,7 @@ module Samagotchi
96
98
  # The sink's side (under @mutex): the last summary per session.
97
99
  @mutex = Mutex.new
98
100
  @by_id = {}
101
+ @updates_seen = {}
99
102
  @seeded = false
100
103
  @early = []
101
104
  # The sender's side (its thread only, or #pump in a spec).
@@ -185,9 +188,28 @@ module Samagotchi
185
188
  prev = @by_id.delete(event.data[:id])
186
189
  keys = Attention.closed_keys(prev, nil)
187
190
  @queue << Close.new(keys: keys) unless keys.empty?
191
+ when "chi"
192
+ notice_update(event)
188
193
  end
189
194
  end
190
195
 
196
+ def notice_update(event)
197
+ return unless event.data[:update_notice]
198
+
199
+ version = event.data[:available]
200
+ return unless version.is_a?(String) && !version.empty? && !@updates_seen[version]
201
+
202
+ @updates_seen[version] = true
203
+ attention = Attention::Event.new(reason: "update", session_id: nil, key: "chi:update:#{version}", hold: nil)
204
+ @queue << Show.new(attention: attention, title: "chi #{version} is available",
205
+ body: update_body(event.data[:update_blocked]), at: @now.call, url: "/")
206
+ end
207
+
208
+ # A blocked install (a checkout, Bundler, an unwritable gem dir) names its own command on the page.
209
+ def update_body(blocked)
210
+ blocked ? "Open chi web for how to update this install." : "Run chi update to install it."
211
+ end
212
+
191
213
  # Cards carry no time: they seed as they are.
192
214
  def catch_up(summaries)
193
215
  sent = nil
@@ -328,8 +350,13 @@ module Samagotchi
328
350
  def deliver(subs, payload, show, final: false)
329
351
  reason = show.attention.reason
330
352
  waiting = Attention.waiting?(reason)
353
+ ttl = if reason == "update"
354
+ UPDATE_TTL
355
+ else
356
+ waiting ? SHOW_TTL : EVENT_TTL
357
+ end
331
358
  subs.map do |sub|
332
- result = @client.deliver(sub, payload, ttl: waiting ? SHOW_TTL : EVENT_TTL, urgency: waiting ? "high" : "normal")
359
+ result = @client.deliver(sub, payload, ttl: ttl, urgency: waiting ? "high" : "normal")
333
360
  if result.retry_after && !final
334
361
  retry_later(show, sub, result)
335
362
  next false
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "rack"
5
5
  require "rackup/handler/webrick"
6
+ require "rbconfig"
6
7
  require "uri"
7
8
  require "webrick"
8
9
 
@@ -17,12 +18,16 @@ require_relative "qr"
17
18
  require_relative "running"
18
19
  require_relative "start_dir"
19
20
  require_relative "session_hub"
21
+ require_relative "update_check"
20
22
  require_relative "token"
21
23
  require_relative "tunnel"
22
24
  require_relative "../config"
25
+ require_relative "../child_env"
26
+ require_relative "../installed_gem"
23
27
  require_relative "../paths"
24
28
  require_relative "../project_scope"
25
29
  require_relative "../log"
30
+ require_relative "../session_manager"
26
31
  require_relative "../version"
27
32
  require_relative "../cli/exit"
28
33
 
@@ -64,6 +69,8 @@ module Samagotchi
64
69
  DEFAULT_HOST = "127.0.0.1"
65
70
  # How long `chi web` waits for a server already on its port to answer.
66
71
  PROBE_TIMEOUT = 0.3
72
+ RESTARTED_ENV = "SAMAGOTCHI_WEB_RESTARTED"
73
+ ADOPT_PIDS_ENV = "SAMAGOTCHI_WEB_ADOPT_PIDS"
67
74
 
68
75
  # The raw header names next to Rack's env (ProxyGate's identity check):
69
76
  # chi web is the only WEBrick server chi runs.
@@ -86,7 +93,9 @@ module Samagotchi
86
93
  def self.launch(port: nil, host: nil, scope: "project", start_dir: nil, open_browser: false, markdown: false,
87
94
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
88
95
  editor: Config::BY_KEY["web.editor"].default, new_token: false,
89
- working_set: Config::BY_KEY["web.working_set"].default, tunnel: nil, start_dir_from: :flag)
96
+ working_set: Config::BY_KEY["web.working_set"].default, tunnel: nil, start_dir_from: :flag,
97
+ restarted: false, restart_argv: ["web"], exec: nil)
98
+ handed_on = false # a :restart leaves Serve to the next process, a run turns it off itself
90
99
  start_given = !start_dir.to_s.strip.empty?
91
100
  start_dir = StartDir.resolve(start_dir, from: start_dir_from)
92
101
  rotate_token if new_token
@@ -119,6 +128,8 @@ module Samagotchi
119
128
  warn in_use_message(port)
120
129
  1
121
130
  else
131
+ provider = nil
132
+ plan = nil
122
133
  if tunnel
123
134
  provider = Tunnel.for(tunnel)
124
135
  plan = provider.prepare(port: port, config_owner: Samagotchi::Config.get("web.proxy.owner"),
@@ -141,11 +152,14 @@ module Samagotchi
141
152
  public_url: public_url, start_dir: start_dir }
142
153
  started = if plan
143
154
  with_tunnel(provider, plan) do |up|
144
- start(**options, public_notes: provider.start_lines(plan), on_bound: up)
155
+ start(**options, public_notes: provider.start_lines(plan, restarted: restarted), on_bound: up)
145
156
  end
146
157
  else
147
158
  start(**options)
148
159
  end
160
+ handed_on = !!started
161
+ return reexec(restart_argv, provider: provider, plan: plan, exec: exec) if started == :restart
162
+
149
163
  started ? 0 : 1
150
164
  end
151
165
  rescue StartDir::Invalid, Lan::Error, Grants::Insecure, PublicUrl::Invalid, Tunnel::Refused, Push::Refused, Push::Store::Insecure => e
@@ -156,6 +170,11 @@ module Samagotchi
156
170
  # status a shell gives a command it interrupted.
157
171
  puts "#{"\n" if $stdout.tty?}Chi Web stopped."
158
172
  CLI::Exit::INTERRUPTED
173
+ ensure
174
+ # A restarted chi web that didn't get to serve (a refusal, the port
175
+ # race, a bad start folder): the old process's Serve handler it was
176
+ # to adopt goes off, never left forwarding to nothing.
177
+ stop_restarted_tunnel(tunnel, resolve_port(port)) if restarted && tunnel && !handed_on
159
178
  end
160
179
 
161
180
  # A second chi web given a start folder (--start-dir, web.start_dir)
@@ -176,16 +195,87 @@ module Samagotchi
176
195
  # (its command runs in its own process group, and Serve keeps what it
177
196
  # set even if chi stopped waiting), so it can't land between the serve
178
197
  # going on and the teardown being due; #up is bounded by its timeout.
198
+ # A :restart result leaves Serve up for the exec'd chi web to adopt.
179
199
  def self.with_tunnel(provider, plan)
180
200
  attempted = false
181
- yield(lambda do
201
+ result = yield(lambda do
182
202
  Thread.handle_interrupt(SignalException => :never) do
183
203
  attempted = true
184
204
  provider.up(plan)
185
205
  end
186
206
  end)
207
+ result
187
208
  ensure
188
- tunnel_down(provider, plan) if attempted
209
+ tunnel_down(provider, plan) if attempted && result != :restart
210
+ end
211
+
212
+ # Replace this process only after its listener and hub have stopped.
213
+ # Starter PIDs are children of this process; workers are detached and
214
+ # must not be adopted or signaled.
215
+ # No line before the exec: one on a hung-up terminal raises EIO, and
216
+ # the restart would end in a dead chi web; the log says it.
217
+ def self.reexec(argv, provider: nil, plan: nil, exec: nil)
218
+ program = reexec_program
219
+ raise ArgumentError, "RubyGems chi wrapper not found" if program.to_s.empty?
220
+
221
+ env = ChildEnv.unbundled.merge(
222
+ RESTARTED_ENV => "1",
223
+ ADOPT_PIDS_ENV => SessionManager.live_spawn_pids.join(",")
224
+ )
225
+ Samagotchi::Log.info(:web, "restart", program: program)
226
+ runner = exec || ->(environment, *args) { Kernel.exec(environment, *args) }
227
+ runner.call(env, RbConfig.ruby, program, *argv)
228
+ rescue SystemCallError, ArgumentError => e
229
+ tunnel_down(provider, plan) if provider && plan
230
+ warn "Error: couldn't restart chi web: #{e.message}"
231
+ 1
232
+ end
233
+
234
+ # What a restart execs: the installed gem's wrapper (it activates the
235
+ # newest installed chi), this bin/chi in a checkout; nil for a gem
236
+ # installed without one (--bindir).
237
+ def self.reexec_program
238
+ gem_spec = InstalledGem.spec
239
+ gem_spec ? InstalledGem.wrapper(gem_spec) : $PROGRAM_NAME
240
+ end
241
+
242
+ # bin/chi's first step: the restart marker and the starters to adopt
243
+ # out of +env+, before anything (a worker, chi update) is spawned with
244
+ # it; the starters are adopted only under the marker.
245
+ # @return [Boolean] whether this chi web is a restarted one
246
+ def self.take_restart_env!(env = ENV, detach: Process.method(:detach))
247
+ restarted = env.delete(RESTARTED_ENV) == "1"
248
+ adopt_reexec_children!(env: env, detach: detach) if restarted
249
+ env.delete(ADOPT_PIDS_ENV)
250
+ restarted
251
+ end
252
+
253
+ # Adopt only the recorded child starters. They may have exited in the
254
+ # interval between collecting their PIDs and the new process starting.
255
+ def self.adopt_reexec_children!(env: ENV, detach: Process.method(:detach))
256
+ pids = env.delete(ADOPT_PIDS_ENV).to_s
257
+ pids.split(",").reject(&:empty?).each do |pid|
258
+ child_pid = Integer(pid, 10)
259
+ begin
260
+ detach.call(child_pid)
261
+ rescue Errno::ECHILD
262
+ nil
263
+ end
264
+ end
265
+ end
266
+
267
+ # Turns off the Serve handler a restarted chi web was to adopt, only
268
+ # when it is still exactly this port's (prepare says adopted); a
269
+ # foreign one, or none, stays as it is. Never raises: the start's own
270
+ # error is the one to say.
271
+ def self.stop_restarted_tunnel(tunnel, port)
272
+ provider = Tunnel.for(tunnel)
273
+ plan = provider.prepare(port: port, config_owner: Samagotchi::Config.get("web.proxy.owner"),
274
+ config_public_url: Samagotchi::Config.get("web.public_url"),
275
+ config_identity_header: Samagotchi::Config.get("web.proxy.identity_header"))
276
+ tunnel_down(provider, plan) if plan.adopted
277
+ rescue StandardError => e
278
+ Samagotchi::Log.warn(:web, "restart_tunnel_left", error: e.class.name, msg: e.message)
189
279
  end
190
280
 
191
281
  # The tunnel's teardown, before any line (a hung-up terminal can't
@@ -229,12 +319,13 @@ module Samagotchi
229
319
  # @param on_bound [#call, nil] run once the port is bound, before any
230
320
  # line (the tunnel's #up); what it raises ends the run
231
321
  # @param start_dir [String] the folder chi web starts in (App's)
232
- # @return [Boolean] false when the port was taken (said so on stderr)
322
+ # @return [Boolean, :restart] false when the port was taken (said so on stderr);
323
+ # :restart when the owner requested an in-place process replacement
233
324
  def self.start(port: nil, host: nil, url: nil, open_browser: false, state_dir: nil, manager: nil, markdown: false,
234
325
  view: Config::BY_KEY["web.view"].default, annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
235
326
  editor: Config::BY_KEY["web.editor"].default, token_path: Token.path,
236
327
  working_set: Config::BY_KEY["web.working_set"].default, public_url: nil, public_notes: [],
237
- on_bound: nil, start_dir: Dir.pwd)
328
+ on_bound: nil, start_dir: Dir.pwd, update_check: nil)
238
329
  config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :web) do |message|
239
330
  Samagotchi::Log.warn(:config, "restart_required", echo: "Warning: config: #{message}")
240
331
  end
@@ -243,6 +334,7 @@ module Samagotchi
243
334
  url ||= "http://#{url_host(host)}:#{port}/"
244
335
 
245
336
  hub ||= SessionHub.new(state_dir: state_dir || Session.default_state_dir, manager: manager || SessionManager)
337
+ update_check ||= UpdateCheck.new(hub: hub)
246
338
  Token.load_or_create(token_path) if lan || public_url
247
339
  lan_option = { ip: lan.ip, token: Token::Source.new(token_path) } if lan
248
340
  public_token = Token::Source.new(token_path) if public_url
@@ -250,6 +342,25 @@ module Samagotchi
250
342
  app = App.new(manager: manager, state_dir: state_dir, markdown: markdown, view: view,
251
343
  annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option, working_set: working_set,
252
344
  public_url: public_url, public_token: public_token, push: push, start_dir: start_dir)
345
+ webrick = nil
346
+ restart_mutex = Mutex.new
347
+ restart_requested = false
348
+ restart_thread = nil
349
+ if app.respond_to?(:restart_web=)
350
+ app.restart_web = lambda do
351
+ restart_mutex.synchronize do
352
+ next false if restart_requested
353
+ next :no_wrapper if reexec_program.to_s.empty?
354
+
355
+ restart_requested = true
356
+ restart_thread = Thread.new do
357
+ sleep 0.2
358
+ webrick&.shutdown
359
+ end
360
+ true
361
+ end
362
+ end
363
+ end
253
364
  Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
254
365
  Samagotchi::Log.info(:web, "lan", ip: lan.ip, interface: lan.interface) if lan
255
366
  if public_url
@@ -267,10 +378,10 @@ module Samagotchi
267
378
  Samagotchi::Log.info(:web, "push", devices: push.store.for_origin(push.origin).size)
268
379
  end
269
380
  hub.start
381
+ update_check.start
270
382
  # Said once the port is bound: a second chi web racing for it gets
271
383
  # the in-use line instead.
272
384
  running_path = Running.path(state_dir: state_dir)
273
- webrick = nil
274
385
  callback_unfinished = false
275
386
  started = lambda do
276
387
  callback_unfinished = true
@@ -302,7 +413,7 @@ module Samagotchi
302
413
  app.server_running = -> { server.status == :Running }
303
414
  listen_lan(server, lan, port) if lan
304
415
  end
305
- true
416
+ restart_requested ? :restart : true
306
417
  rescue Errno::EADDRINUSE
307
418
  warn in_use_message(port)
308
419
  false
@@ -316,8 +427,10 @@ module Samagotchi
316
427
  Running.clear(path: running_path) if running_path
317
428
  config_restart_unsubscribe&.call
318
429
  notifier&.stop
430
+ update_check&.stop
319
431
  hub&.stop
320
432
  Samagotchi::Log.info(:web, "stop") if app
433
+ restart_thread&.join
321
434
  end
322
435
 
323
436
  class LanListenError < StandardError; end
@@ -13,6 +13,7 @@ require_relative "../model_profile"
13
13
  require_relative "../client_id"
14
14
  require_relative "../children_status"
15
15
  require_relative "../session_metrics"
16
+ require_relative "../version"
16
17
 
17
18
  module Samagotchi
18
19
  module Web
@@ -273,7 +274,7 @@ module Samagotchi
273
274
  model_name: session_access.show(id).model_name,
274
275
  default_model: ModelProfile.default_model_name,
275
276
  parent_strategy: strategy_json(session_access.parent_strategy(id)),
276
- installed: @hub&.installed })
277
+ **(@hub&.chi_state || { version: VERSION, installed: nil, available: nil, update_blocked: nil }) })
277
278
  rescue SessionManager::OwnedByTUI => e
278
279
  owned_by_tui(e)
279
280
  rescue SessionAccess::Refused => e
@@ -40,8 +40,8 @@ module Samagotchi
40
40
 
41
41
  # What a subscriber gets: `session` with {session: summary} for an
42
42
  # upsert, `session_gone` with {id:} for a removal, `chi` with
43
- # {version:, installed:} when the newest installed chi changed (checked
44
- # with the full probe; #installed has it for a new tab). seq is monotonic per
43
+ # {version:, installed:, available:, update_blocked:} when chi version
44
+ # state changes. seq is monotonic per
45
45
  # hub, for the log's sake: there is no replay.
46
46
  Event = Struct.new(:type, :seq, :data, keyword_init: true)
47
47
 
@@ -78,6 +78,9 @@ module Samagotchi
78
78
  @state_dir = state_dir
79
79
  @installed_versions = installed_versions
80
80
  @installed = nil
81
+ @available = nil
82
+ @update_blocked = nil
83
+ @update_state = nil
81
84
  @manager = manager
82
85
  @now = now || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
83
86
  @scan_interval = scan_interval
@@ -140,6 +143,52 @@ module Samagotchi
140
143
  @monitor.synchronize { @installed }
141
144
  end
142
145
 
146
+ # Look at what is installed now, not at the next full probe (after an
147
+ # install): a change is the usual `chi` event.
148
+ # @return [String, nil] the newest installed chi
149
+ def refresh_installed
150
+ @monitor.synchronize do
151
+ check_installed
152
+ @installed
153
+ end
154
+ end
155
+
156
+ # The complete chi version state used by event snapshots and info routes.
157
+ def chi_state
158
+ @monitor.synchronize do
159
+ { version: VERSION, installed: @installed, available: @available, update_blocked: @update_blocked }
160
+ end
161
+ end
162
+
163
+ # Publish the update check's answer without holding the Monitor while it
164
+ # fetches releases. A newly selected release may carry a one-shot push hint.
165
+ def update_available(available:, blocked:, notify: false)
166
+ @monitor.synchronize do
167
+ changed = available != @available || blocked != @update_blocked
168
+ @available = available
169
+ @update_blocked = blocked
170
+ emit("chi", chi_state.merge(notify ? { update_notice: true } : {})) if changed || notify
171
+ end
172
+ nil
173
+ end
174
+
175
+ # Publish the owner-triggered update's progress or result for current
176
+ # tabs, and retain the latest state for a reconnect's snapshot.
177
+ def publish_update(state:, rows:, progress: nil, operation: nil, version: nil)
178
+ @monitor.synchronize do
179
+ @update_state = { state: state, rows: rows }
180
+ @update_state[:operation] = operation unless operation.nil?
181
+ @update_state[:version] = version unless version.nil?
182
+ @update_state[:progress] = progress unless progress.nil?
183
+ emit("chi_update", @update_state)
184
+ end
185
+ nil
186
+ end
187
+
188
+ def update_state
189
+ @monitor.synchronize { @update_state&.dup }
190
+ end
191
+
143
192
  # Rescan one session now (its file, its folder, its owner), for the
144
193
  # page's own actions: no waiting for the next tick.
145
194
  def touch(id)
@@ -232,7 +281,7 @@ module Samagotchi
232
281
  return if newest == @installed
233
282
 
234
283
  @installed = newest
235
- emit("chi", version: VERSION, installed: newest)
284
+ emit("chi", chi_state)
236
285
  rescue StandardError => e
237
286
  Log.warn(:web, "installed_check_failed", error: e.class.name, msg: e.message)
238
287
  end
@@ -162,10 +162,16 @@ module Samagotchi
162
162
 
163
163
  # What the start lines add: how it is reached and whose login is the
164
164
  # owner, and the Tailnet Lock warning.
165
- def start_lines(plan)
165
+ def start_lines(plan, restarted: false)
166
166
  source = plan.owner_source == :config ? "web.proxy.owner" : "this machine's Tailscale login"
167
167
  via = " via Tailscale Serve (tailnet only), owner #{plan.public_url.owner} (#{source})"
168
- via += "; Serve already forwarded port 443 here, chi web turns it off on exit" if plan.adopted
168
+ if plan.adopted
169
+ via += if restarted
170
+ "; adopted by the restarted chi web; it turns Serve off on exit"
171
+ else
172
+ "; Serve already forwarded port 443 here, chi web turns it off on exit"
173
+ end
174
+ end
169
175
  lines = [via]
170
176
  if plan.lock_off
171
177
  lines << "Tailnet Lock is off: a device Tailscale's servers add to your tailnet could reach this page; " \