samagotchi 0.18.1 → 0.19.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 +85 -1
- data/README.md +4 -3
- data/docs/architecture.md +15 -2
- data/docs/cli.md +34 -8
- data/docs/configuration.md +8 -4
- data/docs/memory.md +4 -0
- data/docs/sessions.md +6 -4
- data/lib/samagotchi/bridge.rb +25 -9
- data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
- data/lib/samagotchi/bridge_client.rb +9 -5
- data/lib/samagotchi/bundle_command.rb +2 -1
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
- data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
- data/lib/samagotchi/engine.rb +9 -3
- data/lib/samagotchi/guardrail_wiring.rb +8 -2
- data/lib/samagotchi/guardrails/approval.rb +10 -4
- data/lib/samagotchi/guardrails/approvals.rb +60 -12
- data/lib/samagotchi/host_registry.rb +22 -4
- data/lib/samagotchi/idle_scheduler.rb +1 -1
- data/lib/samagotchi/installed_versions.rb +91 -0
- data/lib/samagotchi/live_versions.rb +5 -3
- data/lib/samagotchi/memory_bundle/builder.rb +24 -1
- data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
- data/lib/samagotchi/memory_bundle/installer.rb +33 -0
- data/lib/samagotchi/memory_bundle/status.rb +5 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
- data/lib/samagotchi/model_overlay.rb +39 -0
- data/lib/samagotchi/model_profile.rb +4 -0
- data/lib/samagotchi/relay_desk.rb +9 -0
- data/lib/samagotchi/session_manager.rb +166 -11
- data/lib/samagotchi/session_metrics.rb +36 -13
- data/lib/samagotchi/sessions_command.rb +43 -4
- data/lib/samagotchi/system_prompt.rb +10 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -3
- data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
- data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
- data/lib/samagotchi/tools/builtins.rb +21 -5
- data/lib/samagotchi/turn_note.rb +3 -2
- data/lib/samagotchi/update_command.rb +33 -12
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +64 -11
- data/lib/samagotchi/web/public/app.js +102 -29
- data/lib/samagotchi/web/public/data.js +6 -0
- data/lib/samagotchi/web/public/format.js +4 -2
- data/lib/samagotchi/web/public/index.html +11 -0
- data/lib/samagotchi/web/public/question_card.js +12 -0
- data/lib/samagotchi/web/public/question_cards.js +17 -11
- data/lib/samagotchi/web/public/scope.js +15 -6
- data/lib/samagotchi/web/public/session_state.js +4 -0
- data/lib/samagotchi/web/public/turn_events.js +107 -3
- data/lib/samagotchi/web/public/update_notice.js +87 -14
- data/lib/samagotchi/web/server.rb +28 -1
- data/lib/samagotchi/web/session_hub.rb +30 -3
- data/lib/samagotchi/web/session_summary.rb +5 -0
- data/lib/samagotchi/worker.rb +25 -8
- data/lib/samagotchi/worker_idle_exit.rb +31 -2
- data/lib/samagotchi/worker_sidecar.rb +7 -5
- metadata +3 -1
|
@@ -123,6 +123,12 @@ export function stopSession(id, opts = {}) {
|
|
|
123
123
|
return api(`/api/sessions/${id}/stop`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// Hands the session to a new worker on the newest chi installed: 200
|
|
127
|
+
// {status, from_version, version}, or 409 with the reason in words (detail).
|
|
128
|
+
export function restartSession(id, opts = {}) {
|
|
129
|
+
return api(`/api/sessions/${encodeURIComponent(id)}/restart`, { method: "POST", body: JSON.stringify({}), ...opts });
|
|
130
|
+
}
|
|
131
|
+
|
|
126
132
|
// Hides the session (and its delegates) from the lists, kept for good; the
|
|
127
133
|
// server stops a live idle worker first and answers 409 while a turn runs.
|
|
128
134
|
export function archiveSession(id, opts = {}) {
|
|
@@ -86,7 +86,9 @@ export function previewForCard(session) {
|
|
|
86
86
|
|
|
87
87
|
const DELETE_PREVIEW_CHARS = 40;
|
|
88
88
|
|
|
89
|
-
// The confirm before deleting a session (the info bar's and a card's).
|
|
89
|
+
// The confirm before deleting a session (the info bar's and a card's). The
|
|
90
|
+
// list's owner can be stale (a worker idle-exits), so a worker is only
|
|
91
|
+
// "stopped if it's still running", never promised.
|
|
90
92
|
export function deleteConfirmText(session) {
|
|
91
93
|
const n = normalize(session.first_preview || session.last_prompt || "");
|
|
92
94
|
const preview = n.length > DELETE_PREVIEW_CHARS ? `${n.slice(0, DELETE_PREVIEW_CHARS)}\u2026` : n;
|
|
@@ -95,7 +97,7 @@ export function deleteConfirmText(session) {
|
|
|
95
97
|
`Delete session ${shortId}${preview ? ` \u2014 "${preview}"` : ""}?`,
|
|
96
98
|
"Its history, notes and images are removed; this can't be undone.",
|
|
97
99
|
];
|
|
98
|
-
if (session.owner === "worker") lines.push("
|
|
100
|
+
if (session.owner === "worker") lines.push("If its worker is still running, it is stopped first.");
|
|
99
101
|
return lines.join("\n");
|
|
100
102
|
}
|
|
101
103
|
|
|
@@ -242,6 +242,11 @@ main{flex:1;display:flex;flex-direction:column;gap:16px;padding:16px;overflow:au
|
|
|
242
242
|
.question-error{color:var(--danger);font-size:12px;margin-bottom:8px}
|
|
243
243
|
.question-error.hidden{display:none}
|
|
244
244
|
.question-result{margin-top:6px;font-size:12px;color:var(--ok);font-weight:600}
|
|
245
|
+
/* 4.02: a resolved card whose summary already carries the result hides the
|
|
246
|
+
body line; a hand-toggled one keeps it (its expanded body shows it). */
|
|
247
|
+
.question-result.hidden{display:none}
|
|
248
|
+
/* N1: an approval's verdict sits above its command block, not after it. */
|
|
249
|
+
.question-result.verdict{margin-top:0;margin-bottom:8px}
|
|
245
250
|
.bubble.question.cancelled .question-result{color:var(--text-muted)}
|
|
246
251
|
.question-submit{align-self:flex-end;font-size:13px}
|
|
247
252
|
.question-dismiss{font-size:13px;margin-left:8px}
|
|
@@ -554,6 +559,12 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
554
559
|
#infoBar .meta .dir{font-family:ui-monospace,monospace;font-size:11px;color:var(--text-muted);word-break:break-all}
|
|
555
560
|
#infoBar .meta .model{font-size:11px;padding:2px 6px;border-radius:6px;background:var(--bg-hover);color:var(--text-3)}
|
|
556
561
|
#infoBar .meta .model.served-mismatch{background:var(--warn-bg);color:var(--warn-bright);border:1px solid var(--warn-border)}
|
|
562
|
+
/* The open session's worker runs an older chi: its version, and Restart when it can. */
|
|
563
|
+
#infoBar .meta .worker-badge{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:2px 2px 2px 6px;border-radius:6px;background:var(--warn-bg);color:var(--warn-bright);border:1px solid var(--warn-border);white-space:nowrap}
|
|
564
|
+
#infoBar .meta .worker-badge:not(:has(button)){padding-right:6px}
|
|
565
|
+
#infoBar .meta .worker-restart{padding:1px 8px;border-radius:5px;font-size:11px;font-weight:600;background:var(--bg-raised);color:var(--warn-bright);border:1px solid var(--warn-border)}
|
|
566
|
+
#infoBar .meta .worker-restart:hover:not(:disabled){background:var(--warn-border);color:#fff}
|
|
567
|
+
#infoBar .meta .worker-restart:disabled{opacity:.7;cursor:default}
|
|
557
568
|
#infoBar .preview-line{font-size:13px;word-break:break-word;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;line-height:1.4;max-height:2.8em}
|
|
558
569
|
#infoBar .preview-line .preview, #infoBar .preview-line .mem{word-break:break-word}
|
|
559
570
|
#infoBar .mem{font-size:12px;color:var(--text-muted)}
|
|
@@ -161,6 +161,18 @@ export function approvalAllowed(pq, answer) {
|
|
|
161
161
|
return resultText(pq, { answer }).startsWith("Allowed");
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
+
// What the card says when an answer or a dismissal didn't go through. 503
|
|
165
|
+
// (the server found no live bridge) reads as the session not running; any
|
|
166
|
+
// other failure — the worker's own (the server maps it to a 502
|
|
167
|
+
// bridge_error) or the connection's — shows what it said, so a worker that
|
|
168
|
+
// failed the request is not mistaken for one that is gone.
|
|
169
|
+
export function answerErrorText(error) {
|
|
170
|
+
const status = error?.status;
|
|
171
|
+
const message = String(error?.message ?? error ?? "");
|
|
172
|
+
if (status === 503 || /not_live/.test(message)) return "Session is not running — restart it to answer.";
|
|
173
|
+
return message;
|
|
174
|
+
}
|
|
175
|
+
|
|
164
176
|
// What a new question does to the card on the page: "same" (the card
|
|
165
177
|
// already shows it: a replay), "keep" (the card is resolved, so it stays in
|
|
166
178
|
// the history and the new one goes under it), "replace" (the card is still
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
import { dismissQuestion, sendAnswer, sendCommand } from "./data.js";
|
|
16
16
|
import { diffHtml } from "./diff_view.js";
|
|
17
17
|
import {
|
|
18
|
-
approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, isContinue, nextCardAction,
|
|
19
|
-
summaryText,
|
|
18
|
+
answerErrorText, approvalAllowed, approvalView, CONTINUE_REASON_PLACEHOLDER, isContinue, nextCardAction,
|
|
19
|
+
relayedToView, resultText, summaryText,
|
|
20
20
|
} from "./question_card.js";
|
|
21
21
|
import { sessionHash } from "./route.js";
|
|
22
22
|
import { clientLabel, continueLine, isOwn } from "./turn_events.js";
|
|
@@ -148,9 +148,7 @@ export function createQuestionCards({
|
|
|
148
148
|
}).catch((e) => {
|
|
149
149
|
submit.disabled = false;
|
|
150
150
|
card.classList.remove("submitting");
|
|
151
|
-
err.textContent =
|
|
152
|
-
? "Session is not running — restart it to answer."
|
|
153
|
-
: e.message;
|
|
151
|
+
err.textContent = answerErrorText(e);
|
|
154
152
|
err.classList.remove("hidden");
|
|
155
153
|
});
|
|
156
154
|
});
|
|
@@ -169,9 +167,7 @@ export function createQuestionCards({
|
|
|
169
167
|
api.dismissQuestion(sessionId(), pq.id).catch((e) => {
|
|
170
168
|
submit.disabled = false;
|
|
171
169
|
dismiss.disabled = false;
|
|
172
|
-
err.textContent =
|
|
173
|
-
? "Session is not running — restart it to answer."
|
|
174
|
-
: e.message;
|
|
170
|
+
err.textContent = answerErrorText(e);
|
|
175
171
|
err.classList.remove("hidden");
|
|
176
172
|
});
|
|
177
173
|
});
|
|
@@ -210,7 +206,7 @@ export function createQuestionCards({
|
|
|
210
206
|
api.sendAnswer(sessionId(), { id: pq.id, selected: [label], ...(freeform ? { freeform } : {}) }).catch((e) => {
|
|
211
207
|
card.querySelectorAll("button, input").forEach((el) => { el.disabled = false; });
|
|
212
208
|
card.classList.remove("submitting");
|
|
213
|
-
err.textContent =
|
|
209
|
+
err.textContent = answerErrorText(e);
|
|
214
210
|
err.classList.remove("hidden");
|
|
215
211
|
});
|
|
216
212
|
};
|
|
@@ -320,14 +316,20 @@ export function createQuestionCards({
|
|
|
320
316
|
const note = doc.createElement("div");
|
|
321
317
|
note.className = "question-result";
|
|
322
318
|
note.textContent = result;
|
|
319
|
+
// N1: an approval's verdict goes before its command block, not after it:
|
|
320
|
+
// with the details expanded the command reads like another tool call.
|
|
321
|
+
const allowed = approvalAllowed(pending, answer);
|
|
322
|
+
if (allowed !== null) note.classList.add("verdict");
|
|
323
323
|
if (cancelled) {
|
|
324
324
|
questionEl.classList.add("cancelled");
|
|
325
325
|
} else {
|
|
326
326
|
questionEl.classList.add("answered");
|
|
327
|
-
if (
|
|
327
|
+
if (allowed === false) questionEl.classList.add("denied");
|
|
328
328
|
markQuestionSelection(answer && Array.isArray(answer.selected) ? answer.selected : []);
|
|
329
329
|
}
|
|
330
|
-
questionEl.
|
|
330
|
+
const before = allowed !== null ? questionEl.querySelector(".approval-details") : null;
|
|
331
|
+
if (before) questionEl.insertBefore(note, before);
|
|
332
|
+
else questionEl.appendChild(note);
|
|
331
333
|
questionEl.classList.remove("submitting");
|
|
332
334
|
disableQuestionInputs();
|
|
333
335
|
questionEl.querySelector(".question-relayed")?.classList.add("hidden");
|
|
@@ -335,6 +337,10 @@ export function createQuestionCards({
|
|
|
335
337
|
// unless the user opened or closed it by hand.
|
|
336
338
|
const summary = questionEl.querySelector("summary");
|
|
337
339
|
if (summary) summary.textContent = summaryText(pending, { answer, cancelled, reason });
|
|
340
|
+
// 4.02: a reopened card's summary already carries the result (the
|
|
341
|
+
// snapshot re-renders it resolved), and the body line repeats it. It
|
|
342
|
+
// stays for a hand-toggled card, whose expanded body the user looks at.
|
|
343
|
+
if (summary && !questionEl.dataset.toggled) note.classList.add("hidden");
|
|
338
344
|
if (!questionEl.dataset.toggled) questionEl.open = false;
|
|
339
345
|
pending = null;
|
|
340
346
|
}
|
|
@@ -10,14 +10,23 @@ export function scopeDir(search) {
|
|
|
10
10
|
|
|
11
11
|
// The same place in the all view: the page without ?dir, keeping the hash.
|
|
12
12
|
// +fromDir+ (the project view's folder) rides along as ?from=, so the all
|
|
13
|
-
// view can link back to it.
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
// view can link back to it. +search+ (the page's location.search) keeps
|
|
14
|
+
// its ?view= (VIEW_PARAM) on the new page.
|
|
15
|
+
export function allScopeHref(hash, fromDir = null, search = "") {
|
|
16
|
+
const params = [fromDir ? `from=${dirParam(fromDir)}` : "", viewParam(search)].filter(Boolean);
|
|
17
|
+
return `/${params.length ? `?${params.join("&")}` : ""}${hash || ""}`;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
// A folder's project view, keeping the hash
|
|
19
|
-
export function projectScopeHref(dir, hash) {
|
|
20
|
-
|
|
20
|
+
// A folder's project view, keeping the hash and +search+'s ?view=.
|
|
21
|
+
export function projectScopeHref(dir, hash, search = "") {
|
|
22
|
+
const view = viewParam(search);
|
|
23
|
+
return `/?dir=${dirParam(dir)}${view ? `&${view}` : ""}${hash || ""}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// "view=<v>" from a location.search string, or "" without one.
|
|
27
|
+
function viewParam(search) {
|
|
28
|
+
const view = new URLSearchParams(search || "").get("view");
|
|
29
|
+
return view ? `view=${encodeURIComponent(view)}` : "";
|
|
21
30
|
}
|
|
22
31
|
|
|
23
32
|
// A folder for the query string, its slashes left readable (as chi web
|
|
@@ -29,6 +29,10 @@ export function emptySessionState() {
|
|
|
29
29
|
owner: null,
|
|
30
30
|
// The session's commands (its worker's, plugins' too) for the / list.
|
|
31
31
|
commands: [],
|
|
32
|
+
// The chi its live worker runs and what it can do (the session card's
|
|
33
|
+
// worker_version / worker_features): the info bar's stale-worker badge.
|
|
34
|
+
workerVersion: null,
|
|
35
|
+
workerFeatures: [],
|
|
32
36
|
timing: normalizeTiming(),
|
|
33
37
|
};
|
|
34
38
|
}
|
|
@@ -20,10 +20,69 @@ export function workerGoneText({ turnRunning, stopped = false }) {
|
|
|
20
20
|
return `\u2715 canceled (${stopped ? "session stopped" : "worker exited"})`;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// How long a turn's answer waits for the after_turn hooks' answer_display
|
|
24
|
+
// before the page stops waiting and pops what it has (4.14).
|
|
25
|
+
export const DISPLAY_WAIT_MS = 20000;
|
|
26
|
+
|
|
27
|
+
// The wait between a turn_completed with display_pending and its
|
|
28
|
+
// answer_display. The worker can die in between (a crash, a stop, an idle
|
|
29
|
+
// exit) and never send one, so the wait also ends on the caller's finish()
|
|
30
|
+
// (workerGone, a re-render) and by the timeout: it must never hold the
|
|
31
|
+
// turn's answer bubble forever.
|
|
32
|
+
// @return {{promise, finish, settled: () => Boolean}}
|
|
33
|
+
export function displayWait({
|
|
34
|
+
timeoutMs = DISPLAY_WAIT_MS, setTimeoutImpl = setTimeout, clearTimeoutImpl = clearTimeout,
|
|
35
|
+
} = {}) {
|
|
36
|
+
let settle = null;
|
|
37
|
+
let done = false;
|
|
38
|
+
const promise = new Promise((resolve) => { settle = resolve; });
|
|
39
|
+
const finish = () => {
|
|
40
|
+
if (done) return;
|
|
41
|
+
done = true;
|
|
42
|
+
clearTimeoutImpl(timer);
|
|
43
|
+
settle();
|
|
44
|
+
};
|
|
45
|
+
const timer = setTimeoutImpl(finish, timeoutMs);
|
|
46
|
+
return { promise, finish, settled: () => done };
|
|
47
|
+
}
|
|
48
|
+
|
|
23
49
|
// This tab's id in the events (`origin.client_id`, turn_enqueued's
|
|
24
|
-
// client_id):
|
|
25
|
-
|
|
26
|
-
|
|
50
|
+
// client_id): kept in sessionStorage per tab, so a reload mid-turn still
|
|
51
|
+
// recognizes this tab's own prompt (4.03), which a fresh random id every
|
|
52
|
+
// load labelled "web". Falls back to a fresh id when sessionStorage is
|
|
53
|
+
// unavailable (a private window that refuses it) or holds nonsense.
|
|
54
|
+
export const CLIENT_ID_KEY = "chi_client_id";
|
|
55
|
+
export function newClientId(storage = safeSessionStorage()) {
|
|
56
|
+
const kept = readClientId(storage);
|
|
57
|
+
if (kept) return kept;
|
|
58
|
+
const id = `web:${Math.random().toString(36).slice(2, 10).padEnd(8, "0")}`;
|
|
59
|
+
writeClientId(storage, id);
|
|
60
|
+
return id;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function safeSessionStorage() {
|
|
64
|
+
try {
|
|
65
|
+
return globalThis.sessionStorage || null;
|
|
66
|
+
} catch (_) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readClientId(storage) {
|
|
72
|
+
try {
|
|
73
|
+
const value = storage?.getItem(CLIENT_ID_KEY);
|
|
74
|
+
return typeof value === "string" && /^web:/.test(value) ? value : null;
|
|
75
|
+
} catch (_) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function writeClientId(storage, id) {
|
|
81
|
+
try {
|
|
82
|
+
storage?.setItem(CLIENT_ID_KEY, id);
|
|
83
|
+
} catch (_) {
|
|
84
|
+
// Full or refused storage: the id stays for this page only.
|
|
85
|
+
}
|
|
27
86
|
}
|
|
28
87
|
|
|
29
88
|
export function isOwn(clientId, myId) {
|
|
@@ -190,6 +249,17 @@ export function startPageReply(text, { images = 0 } = {}) {
|
|
|
190
249
|
return images ? null : webLocalReply(text);
|
|
191
250
|
}
|
|
192
251
|
|
|
252
|
+
// The start page's reply to a first message that needs no session at all
|
|
253
|
+
// (2.29): its own command's reply (startPageReply), or the hint for a
|
|
254
|
+
// command word no command answers (a typo like /modle) — the hint the page
|
|
255
|
+
// shows in a session too, as a command bubble. Decided before the session
|
|
256
|
+
// is created, so a first /stats or /modle leaves none behind. A null here
|
|
257
|
+
// means the message goes to a session (a prompt, or anything with images).
|
|
258
|
+
export function startPageReplyOrHint(text, { commands = [], images = 0 } = {}) {
|
|
259
|
+
if (images) return null;
|
|
260
|
+
return startPageReply(text) || unknownCommandHint(text, commands);
|
|
261
|
+
}
|
|
262
|
+
|
|
193
263
|
// The continue card's answers, as the terminal's continue prompt reads them.
|
|
194
264
|
export function continueLine(choice, reason = "") {
|
|
195
265
|
const why = String(reason || "").trim();
|
|
@@ -257,6 +327,40 @@ export function restoreOnAck(enqueuedId, early, { myId, sentIds }) {
|
|
|
257
327
|
return restoreAction(event, { myId, sentIds, composerEmpty: true });
|
|
258
328
|
}
|
|
259
329
|
|
|
330
|
+
// The /turn this kept entry was for failed: no ack comes, so nothing would
|
|
331
|
+
// take it back. The whole map goes: this tab sends one turn at a time, so
|
|
332
|
+
// every entry waits for an ack that is now never coming (an older one is
|
|
333
|
+
// left over from a send that failed the same way).
|
|
334
|
+
export function dropEarlyRestores(early) {
|
|
335
|
+
const count = early.size;
|
|
336
|
+
early.clear();
|
|
337
|
+
return count;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// The composer's text with a restored prompt put back into it: the restored
|
|
341
|
+
// text alone when nothing is typed, else the typed text with the restored
|
|
342
|
+
// prompt under it (the user typed between the restore and the ack; their
|
|
343
|
+
// text must not be replaced).
|
|
344
|
+
export function restoreInto(refill, current) {
|
|
345
|
+
const restored = String(refill ?? "");
|
|
346
|
+
const typed = String(current ?? "");
|
|
347
|
+
if (!restored) return typed;
|
|
348
|
+
if (!typed.trim()) return restored;
|
|
349
|
+
return `${typed.replace(/\n+$/, "")}\n${restored}`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// What the composer holds once a /turn ack lands (4.13): the composer still
|
|
353
|
+
// carries the text that was just sent, so that text goes (it is the history's
|
|
354
|
+
// now) — but text typed since the send stays, and a prompt restored before
|
|
355
|
+
// this ack (restoreOnAck's refill) is put back without replacing it.
|
|
356
|
+
// @param sent the composer's text when the turn was sent
|
|
357
|
+
// @param current the composer's text as the ack lands
|
|
358
|
+
// @param refill the prompt an early restore handed back, if any
|
|
359
|
+
export function composerAfterAck({ sent = "", current = "", refill = "" } = {}) {
|
|
360
|
+
const kept = String(current ?? "") === String(sent ?? "") ? "" : current;
|
|
361
|
+
return restoreInto(refill, kept);
|
|
362
|
+
}
|
|
363
|
+
|
|
260
364
|
// What an event does to the prompt bubbles, as a list of ops for app.js:
|
|
261
365
|
// add — a bubble for a prompt this tab hasn't shown (state queued /
|
|
262
366
|
// started / steered, labelled by its sender)
|
|
@@ -1,15 +1,88 @@
|
|
|
1
|
-
// An open tab keeps its JS across a
|
|
2
|
-
// just reconnect), so after an upgrade
|
|
3
|
-
//
|
|
4
|
-
// GET /api/events snapshot names
|
|
5
|
-
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
1
|
+
// What the page says about chi versions. An open tab keeps its JS across a
|
|
2
|
+
// `chi web` restart (its event streams just reconnect), so after an upgrade
|
|
3
|
+
// old page code talks to a new server: the page knows the version that
|
|
4
|
+
// served it (body data-version), and each GET /api/events snapshot names
|
|
5
|
+
// the one serving now and the newest installed (`chi` frames when that
|
|
6
|
+
// changes).
|
|
7
|
+
|
|
8
|
+
const known = (v) => typeof v === "string" && v !== "";
|
|
9
|
+
|
|
10
|
+
// Gem::Version's order, near enough: segments of digits or letters, a
|
|
11
|
+
// missing one counts as 0, and letters (a prerelease: 0.19.0.pre1) sort
|
|
12
|
+
// below any number. <0, 0 or >0.
|
|
13
|
+
export function compareVersions(a, b) {
|
|
14
|
+
const parts = (v) => (v.match(/[0-9]+|[a-z]+/gi) || []).map((p) => (/^[0-9]+$/.test(p) ? Number(p) : p));
|
|
15
|
+
const x = parts(a);
|
|
16
|
+
const y = parts(b);
|
|
17
|
+
for (let i = 0; i < Math.max(x.length, y.length); i++) {
|
|
18
|
+
const p = i < x.length ? x[i] : 0;
|
|
19
|
+
const q = i < y.length ? y[i] : 0;
|
|
20
|
+
if (p === q) continue;
|
|
21
|
+
if (typeof p === "number" && typeof q === "number") return p - q;
|
|
22
|
+
if (typeof p === "number") return 1;
|
|
23
|
+
if (typeof q === "number") return -1;
|
|
24
|
+
return p < q ? -1 : 1;
|
|
25
|
+
}
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Whether +a+ is a newer version than +b+ (false when either is unknown).
|
|
30
|
+
export function newerVersion(a, b) {
|
|
31
|
+
return known(a) && known(b) && compareVersions(a, b) > 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The toast for the versions as the page knows them, or null:
|
|
35
|
+
// - loaded ≠ served: chi web restarted on another version; reload the page;
|
|
36
|
+
// - installed newer than served: chi web runs an older chi than installed;
|
|
37
|
+
// only its terminal can restart it.
|
|
38
|
+
// Each has a key; one in +shown+ (a Set: what this tab already showed, as
|
|
39
|
+
// every reconnect's snapshot says it again) is not shown twice.
|
|
40
|
+
// @return {{key, text, reload: boolean} | null}
|
|
41
|
+
export function versionNotice({ loaded, served, installed, shown = new Set() } = {}) {
|
|
42
|
+
let notice = null;
|
|
43
|
+
if (known(loaded) && known(served) && loaded !== served) {
|
|
44
|
+
notice = { key: `reload:${served}`, text: `chi was updated to ${served}`, reload: true };
|
|
45
|
+
} else if (newerVersion(installed, served)) {
|
|
46
|
+
notice = {
|
|
47
|
+
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",
|
|
50
|
+
reload: false,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return notice && !shown.has(notice.key) ? notice : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The info bar's badge for the open session's worker when it runs an older
|
|
57
|
+
// chi than the newest installed (or than chi web, while that is unknown),
|
|
58
|
+
// else null. A worker with the "restart" feature offers Restart; an older
|
|
59
|
+
// one can only be stopped (any message then starts it on the newest chi).
|
|
60
|
+
// @return {{text, title, newest, restart: boolean} | null}
|
|
61
|
+
export function workerBadge({ workerVersion, features, installed, served, sessionId = "" } = {}) {
|
|
62
|
+
const newest = known(installed) ? installed : served;
|
|
63
|
+
if (!newerVersion(newest, workerVersion)) return null;
|
|
64
|
+
if (Array.isArray(features) && features.includes("restart")) {
|
|
65
|
+
return {
|
|
66
|
+
text: `chi ${workerVersion}`,
|
|
67
|
+
title: `This session's worker runs chi ${workerVersion}; chi ${newest} is installed. ` +
|
|
68
|
+
`Restart hands the session to a new worker on chi ${newest}.`,
|
|
69
|
+
newest,
|
|
70
|
+
restart: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const stop = `chi sessions stop ${String(sessionId).slice(0, 8)}`;
|
|
74
|
+
return {
|
|
75
|
+
text: `chi ${workerVersion} · ${stop}`,
|
|
76
|
+
title: `This session's worker runs chi ${workerVersion}, which can't restart itself; chi ${newest} is installed. ` +
|
|
77
|
+
`Stop it (${stop}), then any message starts it on chi ${newest}.`,
|
|
78
|
+
newest,
|
|
79
|
+
restart: false,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// What the Restart confirm asks.
|
|
84
|
+
export function restartConfirmText(workerVersion, newest) {
|
|
85
|
+
return `Restart this session's worker on chi ${newest} (it runs ${workerVersion})?\n\n` +
|
|
86
|
+
"The conversation stays, and this page and attached terminals move to the new worker. " +
|
|
87
|
+
"What lives only in the worker goes: kept /btw answers and plugins' in-memory state.";
|
|
15
88
|
}
|
|
@@ -28,12 +28,16 @@ module Samagotchi
|
|
|
28
28
|
class Server
|
|
29
29
|
# WEBrick logs every exception out of its request loop as an ERROR with
|
|
30
30
|
# a backtrace, among them a browser dropping a kept-alive connection
|
|
31
|
-
# (sock.eof? raising ECONNRESET): harmless, so not logged.
|
|
31
|
+
# (sock.eof? raising ECONNRESET): harmless, so not logged. Nor is the
|
|
32
|
+
# line it logs after answering a body-less POST (no Content-Length),
|
|
33
|
+
# when it tries to drain a body nobody read: the response already went.
|
|
32
34
|
class Log < WEBrick::Log
|
|
33
35
|
DROPPED = [Errno::ECONNRESET, Errno::EPIPE, Errno::ECONNABORTED].freeze
|
|
36
|
+
DROPPED_LINES = ["HTTPRequest#fixup: WEBrick::HTTPStatus::LengthRequired occurred."].freeze
|
|
34
37
|
|
|
35
38
|
def error(msg)
|
|
36
39
|
return if DROPPED.any? { |klass| msg.is_a?(klass) }
|
|
40
|
+
return if msg.is_a?(String) && DROPPED_LINES.include?(msg)
|
|
37
41
|
|
|
38
42
|
super
|
|
39
43
|
end
|
|
@@ -125,6 +129,8 @@ module Samagotchi
|
|
|
125
129
|
annotate_presets: annotate_presets, hub: hub, lan: lan_option)
|
|
126
130
|
Samagotchi::Log.info(:web, "start", url: "http://#{host}:#{port}", version: Samagotchi::VERSION)
|
|
127
131
|
Samagotchi::Log.info(:web, "lan", ip: lan.ip, interface: lan.interface) if lan
|
|
132
|
+
# Subscribed before the first scan, which makes the first check.
|
|
133
|
+
hub.subscribe(method(:on_hub_event))
|
|
128
134
|
hub.start
|
|
129
135
|
# Said once the port is bound: a second chi web racing for it gets
|
|
130
136
|
# the in-use line instead.
|
|
@@ -162,6 +168,27 @@ module Samagotchi
|
|
|
162
168
|
|
|
163
169
|
class LanListenError < StandardError; end
|
|
164
170
|
|
|
171
|
+
# The hub's `chi` event: a newer chi installed than this chi web runs
|
|
172
|
+
# is said once in its terminal (the page says it too).
|
|
173
|
+
def self.on_hub_event(event)
|
|
174
|
+
return unless event.type == "chi"
|
|
175
|
+
|
|
176
|
+
line = installed_line(event.data[:installed])
|
|
177
|
+
return unless line
|
|
178
|
+
|
|
179
|
+
Samagotchi::Log.info(:web, "newer_installed", installed: event.data[:installed], version: Samagotchi::VERSION)
|
|
180
|
+
puts line
|
|
181
|
+
$stdout.flush
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# nil unless +installed+ is newer than this chi web.
|
|
185
|
+
def self.installed_line(installed, running = Samagotchi::VERSION)
|
|
186
|
+
return nil unless InstalledVersions.newer?(installed, running)
|
|
187
|
+
|
|
188
|
+
"chi #{installed} is installed; this chi web runs #{running}. Restart it: Ctrl-C here, then chi web " \
|
|
189
|
+
"(new sessions already run #{installed})"
|
|
190
|
+
end
|
|
191
|
+
|
|
165
192
|
# The LAN address's listener, next to the loopback one WEBrick made.
|
|
166
193
|
# The loopback socket is bound by now: on a failure it is closed
|
|
167
194
|
# here, as WEBrick never starts.
|
|
@@ -9,6 +9,8 @@ require_relative "../session_manager"
|
|
|
9
9
|
require_relative "../bridge_client"
|
|
10
10
|
require_relative "../worker_sidecar"
|
|
11
11
|
require_relative "../log"
|
|
12
|
+
require_relative "../installed_versions"
|
|
13
|
+
require_relative "../version"
|
|
12
14
|
require_relative "session_summary"
|
|
13
15
|
|
|
14
16
|
module Samagotchi
|
|
@@ -37,7 +39,9 @@ module Samagotchi
|
|
|
37
39
|
FULL_PROBE_INTERVAL = 10.0
|
|
38
40
|
|
|
39
41
|
# What a subscriber gets: `session` with {session: summary} for an
|
|
40
|
-
# upsert, `session_gone` with {id:} for a removal
|
|
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
|
|
41
45
|
# hub, for the log's sake: there is no replay.
|
|
42
46
|
Event = Struct.new(:type, :seq, :data, keyword_init: true)
|
|
43
47
|
|
|
@@ -68,9 +72,12 @@ module Samagotchi
|
|
|
68
72
|
# @param manager [#session_owner, #retention_sweep_if_due] SessionManager,
|
|
69
73
|
# or a stand-in
|
|
70
74
|
# @param now [#call] a monotonic clock in seconds (specs drive it)
|
|
75
|
+
# @param installed_versions [#newest] what is installed (InstalledVersions)
|
|
71
76
|
def initialize(state_dir:, manager: SessionManager, now: nil, scan_interval: SCAN_INTERVAL,
|
|
72
|
-
full_probe_interval: FULL_PROBE_INTERVAL)
|
|
77
|
+
full_probe_interval: FULL_PROBE_INTERVAL, installed_versions: InstalledVersions.new)
|
|
73
78
|
@state_dir = state_dir
|
|
79
|
+
@installed_versions = installed_versions
|
|
80
|
+
@installed = nil
|
|
74
81
|
@manager = manager
|
|
75
82
|
@now = now || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
76
83
|
@scan_interval = scan_interval
|
|
@@ -120,11 +127,19 @@ module Samagotchi
|
|
|
120
127
|
full = full_probe_due?
|
|
121
128
|
scan_files
|
|
122
129
|
@sessions.each_key { |id| refresh(id, probe: full) }
|
|
123
|
-
|
|
130
|
+
if full
|
|
131
|
+
sweep
|
|
132
|
+
check_installed
|
|
133
|
+
end
|
|
124
134
|
end
|
|
125
135
|
nil
|
|
126
136
|
end
|
|
127
137
|
|
|
138
|
+
# The newest installed chi as of the last full probe (nil: unknown).
|
|
139
|
+
def installed
|
|
140
|
+
@monitor.synchronize { @installed }
|
|
141
|
+
end
|
|
142
|
+
|
|
128
143
|
# Rescan one session now (its file, its folder, its owner), for the
|
|
129
144
|
# page's own actions: no waiting for the next tick.
|
|
130
145
|
def touch(id)
|
|
@@ -202,6 +217,18 @@ module Samagotchi
|
|
|
202
217
|
nil
|
|
203
218
|
end
|
|
204
219
|
|
|
220
|
+
# A change (the first look too, unless it finds nothing) is a `chi`
|
|
221
|
+
# event: chi web's terminal line and the open tabs hear it.
|
|
222
|
+
def check_installed
|
|
223
|
+
newest = @installed_versions.newest
|
|
224
|
+
return if newest == @installed
|
|
225
|
+
|
|
226
|
+
@installed = newest
|
|
227
|
+
emit("chi", version: VERSION, installed: newest)
|
|
228
|
+
rescue StandardError => e
|
|
229
|
+
Log.warn(:web, "installed_check_failed", error: e.class.name, msg: e.message)
|
|
230
|
+
end
|
|
231
|
+
|
|
205
232
|
# The state dir's files: a new id is parsed and emitted, an id gone
|
|
206
233
|
# from disk is dropped, a changed file is re-parsed. Skipped when the
|
|
207
234
|
# dir's mtime says nothing was renamed in or unlinked since.
|
|
@@ -30,6 +30,7 @@ module Samagotchi
|
|
|
30
30
|
def build(session, owner:, session_dir:, status: displayed_status(session, owner: owner), root_cache: nil)
|
|
31
31
|
used = Array(session.used_memory_names)
|
|
32
32
|
up = bridge_up?(session_dir, owner)
|
|
33
|
+
sidecar = up ? WorkerSidecar.read(session_dir) : nil
|
|
33
34
|
{
|
|
34
35
|
id: session.id,
|
|
35
36
|
status: status,
|
|
@@ -59,6 +60,10 @@ module Samagotchi
|
|
|
59
60
|
# A worker is reachable: its Bridge sidecar is there and the
|
|
60
61
|
# owner lock is held. A sidecar a dead worker left is not up.
|
|
61
62
|
bridge_up: up,
|
|
63
|
+
# The chi the live worker runs and what it can do (its sidecar;
|
|
64
|
+
# nil and [] without one): the page's stale-worker badge.
|
|
65
|
+
worker_version: sidecar&.version,
|
|
66
|
+
worker_features: sidecar ? sidecar.features : [],
|
|
62
67
|
# For the tab's notifications (notify.js): an open question, the
|
|
63
68
|
# running turn's open card with actions, and how the last turn
|
|
64
69
|
# ended. A question saved by a worker that died is not open, nor a
|