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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +58 -1
- data/bin/chi +13 -2
- data/docs/architecture.md +36 -7
- data/docs/cli.md +48 -3
- data/docs/configuration.md +31 -6
- data/docs/desktop.md +51 -3
- data/docs/guardrails.md +112 -15
- data/docs/internals/engine.md +11 -4
- data/docs/internals/participants.md +67 -17
- data/docs/internals/prompt-caching.md +19 -7
- data/docs/internals/web-push.md +7 -3
- data/docs/sessions.md +7 -3
- data/docs/testing.md +2 -1
- data/lib/samagotchi/bridge.rb +4 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +7 -1
- data/lib/samagotchi/delivery.rb +7 -0
- data/lib/samagotchi/desktop/macos/App.swift +9 -1
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +129 -17
- data/lib/samagotchi/desktop/macos/Images.swift +4 -3
- data/lib/samagotchi/desktop/macos/Panel.swift +315 -80
- data/lib/samagotchi/desktop/macos/Sessions.swift +272 -0
- data/lib/samagotchi/engine.rb +200 -29
- data/lib/samagotchi/gem_update.rb +41 -4
- data/lib/samagotchi/guardrail_wiring.rb +45 -12
- data/lib/samagotchi/guardrails/approval.rb +42 -4
- data/lib/samagotchi/guardrails/gate.rb +1 -1
- data/lib/samagotchi/guardrails/pair_boundary.rb +172 -0
- data/lib/samagotchi/guardrails/pair_command.rb +365 -0
- data/lib/samagotchi/guardrails/review_paths.rb +41 -7
- data/lib/samagotchi/guardrails/shell_lex.rb +7 -5
- data/lib/samagotchi/guardrails/shell_paths.rb +10 -1
- data/lib/samagotchi/guardrails/verdict.rb +7 -0
- data/lib/samagotchi/guardrails.rb +1 -0
- data/lib/samagotchi/guardrails_command.rb +6 -1
- data/lib/samagotchi/kernel_loop.rb +20 -0
- data/lib/samagotchi/llm/chat_loop.rb +4 -0
- data/lib/samagotchi/participant_mode.rb +77 -3
- data/lib/samagotchi/participants.rb +7 -5
- data/lib/samagotchi/review_apply.rb +10 -2
- data/lib/samagotchi/review_start.rb +38 -10
- data/lib/samagotchi/run_options.rb +43 -2
- data/lib/samagotchi/session_access/local.rb +5 -2
- data/lib/samagotchi/session_manager.rb +11 -0
- data/lib/samagotchi/session_participants.rb +18 -3
- data/lib/samagotchi/share_command.rb +2 -2
- data/lib/samagotchi/steer.rb +7 -1
- data/lib/samagotchi/system_one/client.rb +10 -2
- data/lib/samagotchi/system_prompt.rb +20 -4
- data/lib/samagotchi/terminal_ui/question_prompt.rb +17 -1
- data/lib/samagotchi/tool_runner.rb +10 -1
- data/lib/samagotchi/toolkit_binding.rb +34 -0
- data/lib/samagotchi/toolkit_report_command.rb +6 -4
- data/lib/samagotchi/toolkit_suggest.rb +181 -15
- data/lib/samagotchi/tools/builtins.rb +14 -1
- data/lib/samagotchi/update_command.rb +47 -19
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +204 -9
- data/lib/samagotchi/web/grants.rb +17 -5
- data/lib/samagotchi/web/message_display.rb +6 -1
- data/lib/samagotchi/web/participant_review.rb +3 -1
- data/lib/samagotchi/web/participant_view.rb +12 -4
- data/lib/samagotchi/web/participants_admin.rb +10 -6
- data/lib/samagotchi/web/public/app.js +73 -10
- data/lib/samagotchi/web/public/data.js +17 -0
- data/lib/samagotchi/web/public/delivery_line.js +8 -6
- data/lib/samagotchi/web/public/index.html +21 -2
- data/lib/samagotchi/web/public/participant.html +1 -1
- data/lib/samagotchi/web/public/participant.js +10 -12
- data/lib/samagotchi/web/public/participant_model.js +20 -8
- data/lib/samagotchi/web/public/participant_names.js +29 -8
- data/lib/samagotchi/web/public/participants_panel.js +17 -3
- data/lib/samagotchi/web/public/question_card.js +12 -2
- data/lib/samagotchi/web/public/question_cards.js +7 -0
- data/lib/samagotchi/web/public/stream_handlers.js +6 -2
- data/lib/samagotchi/web/public/toasts.js +13 -5
- data/lib/samagotchi/web/public/tools_overview.js +42 -0
- data/lib/samagotchi/web/public/turn_events.js +5 -4
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +6 -4
- data/lib/samagotchi/web/public/update_notice.js +33 -4
- data/lib/samagotchi/web/public/update_sheet.js +158 -0
- data/lib/samagotchi/web/push/notifier.rb +28 -1
- data/lib/samagotchi/web/server.rb +121 -8
- data/lib/samagotchi/web/session_access_routes.rb +2 -1
- data/lib/samagotchi/web/session_hub.rb +52 -3
- data/lib/samagotchi/web/tunnel/tailscale.rb +8 -2
- data/lib/samagotchi/web/update_check.rb +186 -0
- data/lib/samagotchi/worker.rb +44 -6
- 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
|
-
//
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
#
|
|
44
|
-
#
|
|
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",
|
|
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
|
-
|
|
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; " \
|