samagotchi 0.121.0 → 0.121.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +36 -11
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +10 -4
- data/docs/configuration.md +7 -3
- data/docs/guardrails.md +48 -19
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/participants.md +3 -1
- data/docs/internals/remote-sessions.md +15 -11
- data/docs/internals/web-push.md +22 -11
- data/docs/plugins.md +6 -2
- data/docs/sessions.md +15 -0
- data/docs/testing.md +41 -1
- data/lib/samagotchi/answer_command.rb +1 -1
- data/lib/samagotchi/bridge.rb +8 -8
- data/lib/samagotchi/bridge_client/event_stream.rb +11 -27
- data/lib/samagotchi/bridge_client/stream_open.rb +79 -0
- data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
- data/lib/samagotchi/bridge_client.rb +52 -65
- data/lib/samagotchi/bridge_secret.rb +9 -7
- data/lib/samagotchi/bundle_command/check.rb +27 -2
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_move.rb +5 -7
- data/lib/samagotchi/children_status.rb +2 -1
- data/lib/samagotchi/guardrail_wiring.rb +19 -2
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
- data/lib/samagotchi/guardrails_command.rb +41 -6
- data/lib/samagotchi/image_store.rb +6 -3
- data/lib/samagotchi/interrupted_turn.rb +6 -3
- data/lib/samagotchi/live_versions.rb +6 -7
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
- data/lib/samagotchi/plugin/api.rb +8 -5
- data/lib/samagotchi/plugin/sessions.rb +13 -4
- data/lib/samagotchi/push_command.rb +1 -1
- data/lib/samagotchi/relay_verifier.rb +2 -3
- data/lib/samagotchi/relay_watcher.rb +2 -2
- data/lib/samagotchi/remote_attach_command.rb +1 -1
- data/lib/samagotchi/reply_wait/poll.rb +8 -2
- data/lib/samagotchi/reply_wait.rb +4 -3
- data/lib/samagotchi/review_command.rb +1 -1
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session.rb +4 -0
- data/lib/samagotchi/session_access/local.rb +18 -8
- data/lib/samagotchi/session_access/remote.rb +5 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +31 -19
- data/lib/samagotchi/session_access.rb +21 -9
- data/lib/samagotchi/session_continue.rb +2 -3
- data/lib/samagotchi/session_manager.rb +118 -28
- data/lib/samagotchi/sessions_command.rb +10 -5
- data/lib/samagotchi/system_one/client.rb +6 -1
- data/lib/samagotchi/system_one/fake_backend.rb +0 -2
- data/lib/samagotchi/system_one/log.rb +21 -6
- data/lib/samagotchi/system_one/self_protect.rb +39 -43
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
- data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
- data/lib/samagotchi/toolkit.rb +68 -2
- data/lib/samagotchi/tools/delegate.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +9 -7
- data/lib/samagotchi/turn_checkpoint.rb +15 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +123 -65
- data/lib/samagotchi/web/participant_access.rb +26 -7
- data/lib/samagotchi/web/participant_review.rb +2 -1
- data/lib/samagotchi/web/participant_view.rb +7 -3
- data/lib/samagotchi/web/participants_admin.rb +13 -0
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/notifications.js +21 -1
- data/lib/samagotchi/web/public/participant.html +3 -2
- data/lib/samagotchi/web/public/participant.js +23 -9
- data/lib/samagotchi/web/public/participants_panel.js +46 -11
- data/lib/samagotchi/web/public/push.js +4 -3
- data/lib/samagotchi/web/public/push_devices.js +17 -3
- data/lib/samagotchi/web/public/review_history.js +14 -2
- data/lib/samagotchi/web/public/review_start.js +1 -3
- data/lib/samagotchi/web/public/session_actions.js +14 -2
- data/lib/samagotchi/web/public/storage.js +16 -0
- data/lib/samagotchi/web/push/client.rb +5 -0
- data/lib/samagotchi/web/push/store.rb +16 -3
- data/lib/samagotchi/web/push_routes.rb +3 -2
- data/lib/samagotchi/web/session_access_routes.rb +25 -17
- data/lib/samagotchi/web/session_create.rb +4 -4
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/worker.rb +1 -1
- data/lib/samagotchi/worker_link.rb +213 -0
- data/lib/samagotchi/worker_sidecar.rb +15 -37
- metadata +3 -1
|
@@ -24,7 +24,9 @@ import { appendQuote } from "./annotations.js";
|
|
|
24
24
|
import { messageAuthorData, participantQuote, participantSource } from "./participant_annotate.js";
|
|
25
25
|
import { mountComposer } from "./composer_mount.js";
|
|
26
26
|
import { PROMPT_LINE_PX } from "./composer_size.js";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
forgetReviewHistory, pruneReviewHistory, readReviewHistory, recordReviewComment, rememberReviewHistory,
|
|
29
|
+
} from "./review_history.js";
|
|
28
30
|
|
|
29
31
|
// A link's #code= opened in a browser that already has a cookie: traded
|
|
30
32
|
// first, the page reloads without it.
|
|
@@ -54,6 +56,8 @@ let usageTimer = null;
|
|
|
54
56
|
let failures = 0;
|
|
55
57
|
let loadSeq = 0;
|
|
56
58
|
let ended = false;
|
|
59
|
+
// The other links' ↑/↓ lists went at this page's first read (pruneReviewHistory).
|
|
60
|
+
let historyPruned = false;
|
|
57
61
|
// The Plan view: the subject picked, what it last drew, and which view a
|
|
58
62
|
// narrow screen shows (the thread first).
|
|
59
63
|
let subjectPath = null;
|
|
@@ -104,6 +108,8 @@ function showEnded(json) {
|
|
|
104
108
|
document.title = "chi · review link ended";
|
|
105
109
|
}
|
|
106
110
|
|
|
111
|
+
const OFFLINE_TEXT = "Can't reach the session — retrying…";
|
|
112
|
+
|
|
107
113
|
// Reads the review data, draws it and streams from it. One that can't be
|
|
108
114
|
// read (unreachable, a 5xx) keeps what the page shows, says it is
|
|
109
115
|
// retrying, and reads again with a backoff.
|
|
@@ -115,18 +121,22 @@ async function load() {
|
|
|
115
121
|
if (!ok) {
|
|
116
122
|
$("head").hidden = false;
|
|
117
123
|
if (!data) $("title").textContent = "Review";
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
// Said over the thread (#offline), not in the header's rows: the
|
|
125
|
+
// thread stays where it is.
|
|
126
|
+
$("offline").textContent = retries(status) ? OFFLINE_TEXT
|
|
127
|
+
: status === 404 ? "This session is gone." : `The review couldn't be read (HTTP ${status}).`;
|
|
128
|
+
$("offline").hidden = false;
|
|
129
|
+
if (retries(status)) scheduleRetry(retryDelay(failures++));
|
|
124
130
|
return;
|
|
125
131
|
}
|
|
126
132
|
failures = 0;
|
|
127
133
|
$("offline").hidden = true;
|
|
128
134
|
data = json;
|
|
129
|
-
if (!ownerView)
|
|
135
|
+
if (!ownerView) {
|
|
136
|
+
rememberReviewHistory(sessionId, data?.viewer?.id);
|
|
137
|
+
if (!historyPruned) pruneReviewHistory(sessionId, data?.viewer?.id);
|
|
138
|
+
historyPruned = true;
|
|
139
|
+
}
|
|
130
140
|
live = liveFrom(data.current_turn);
|
|
131
141
|
liveStale = false;
|
|
132
142
|
render();
|
|
@@ -468,6 +478,7 @@ async function sendComment() {
|
|
|
468
478
|
if (!prompt) return;
|
|
469
479
|
sending = true;
|
|
470
480
|
$("send").disabled = true;
|
|
481
|
+
showError("");
|
|
471
482
|
let reply;
|
|
472
483
|
try {
|
|
473
484
|
reply = await api(`/api/sessions/${encodeURIComponent(sessionId)}/turn`, {
|
|
@@ -481,7 +492,8 @@ async function sendComment() {
|
|
|
481
492
|
if (ended) return;
|
|
482
493
|
if (ok) {
|
|
483
494
|
recordReviewComment(data?.viewer?.id, prompt);
|
|
484
|
-
comment
|
|
495
|
+
// What was typed while the comment was in flight stays in the box.
|
|
496
|
+
if (comment.value.trim() === prompt) comment.value = "";
|
|
485
497
|
composer.fit();
|
|
486
498
|
composer.drawFoldControls();
|
|
487
499
|
composer.refreshHistory();
|
|
@@ -522,6 +534,8 @@ const composer = ownerView ? null : mountComposer({
|
|
|
522
534
|
fold: { session: () => sessionId || null },
|
|
523
535
|
});
|
|
524
536
|
if (composer) {
|
|
537
|
+
// An error under the box (a refusal, the images toast) goes at the next edit.
|
|
538
|
+
$("comment").addEventListener("input", () => showError(""));
|
|
525
539
|
// The session composer's layout (composer.css), and the fold saved in this browser.
|
|
526
540
|
document.body.classList.add("has-session");
|
|
527
541
|
composer.newVisit();
|
|
@@ -101,6 +101,19 @@ export function chipTemplate(chip) {
|
|
|
101
101
|
title=${chip.title} aria-label=${chip.title} aria-haspopup="dialog"><span class="pc-dot" aria-hidden="true"></span>${chip.text}</button>`;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
// A cap field's dollars (a leading $ allowed): {value}, {value: null} when
|
|
105
|
+
// empty (the Invite then leaves the default cap), or {error}. Both forms read
|
|
106
|
+
// it, so one rule and one wording.
|
|
107
|
+
// @return {{value: number|null}|{error: string}}
|
|
108
|
+
export const CAP_ERROR = "A cap is dollars, 0 to 1000.";
|
|
109
|
+
export function capValue(text) {
|
|
110
|
+
const clean = String(text ?? "").trim();
|
|
111
|
+
if (!clean) return { value: null };
|
|
112
|
+
const value = Number(clean.replace(/^\$/, ""));
|
|
113
|
+
if (!Number.isFinite(value) || value < 0 || value > 1000) return { error: CAP_ERROR };
|
|
114
|
+
return { value };
|
|
115
|
+
}
|
|
116
|
+
|
|
104
117
|
// An email as chi binds a link to it (Grants.clean_identity, which has the
|
|
105
118
|
// last word): one @, no spaces, commas, quotes or angle brackets.
|
|
106
119
|
const EMAIL_RE = /^[^@\s,<>"\\]+@[^@\s,<>"\\]+$/;
|
|
@@ -118,15 +131,17 @@ export function inviteBody({ name, kind, days, cap, identity }) {
|
|
|
118
131
|
if (email) body.identity = email;
|
|
119
132
|
const d = Number(days);
|
|
120
133
|
body.ttl = Math.round((EXPIRY_DAYS.includes(d) ? d : DEFAULT_EXPIRY_DAYS) * 24 * 60 * 60);
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
if (!Number.isFinite(value) || value < 0 || value > 1000) return { error: "A cap is dollars, 0 to 1000." };
|
|
125
|
-
body.cap = value;
|
|
126
|
-
}
|
|
134
|
+
const capped = capValue(cap);
|
|
135
|
+
if (capped.error) return { error: capped.error };
|
|
136
|
+
if (capped.value !== null) body.cap = capped.value;
|
|
127
137
|
return { body };
|
|
128
138
|
}
|
|
129
139
|
|
|
140
|
+
// The toast for an invite whose panel closed before its answer came.
|
|
141
|
+
export function lateInviteText(name, sessionId) {
|
|
142
|
+
return `${name}'s review link for ${String(sessionId).slice(0, 8)} is ready (the panel closed before it came); it shows only once.`;
|
|
143
|
+
}
|
|
144
|
+
|
|
130
145
|
// What Revoke asks first: it can't be undone (chi keeps only the link's
|
|
131
146
|
// digest), as a delete asks.
|
|
132
147
|
export function revokeConfirmText(name) {
|
|
@@ -206,7 +221,7 @@ export function panelTemplate(grants, { now, sessionId, invite = null, error = n
|
|
|
206
221
|
// The control: el (#infoText, where the chip is drawn), session() the open
|
|
207
222
|
// session's id, api {list(id), invite(id, body), revoke(id, pid), cap(id,
|
|
208
223
|
// pid, cap)}, onChange() (the chip's count or presence changed: redraw the
|
|
209
|
-
// bar), toast(text), onNames() (the participants' names changed: name the
|
|
224
|
+
// bar), toast(text, action, {sticky}) (app.js showToast), onNames() (the participants' names changed: name the
|
|
210
225
|
// badges drawn before them, participant_names.js nameUnnamedBadges).
|
|
211
226
|
// startReview({onError}): Start a review of it (a review fork of the open
|
|
212
227
|
// session): the created session, or null (its refusal to onError(text), true
|
|
@@ -300,7 +315,10 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
|
|
|
300
315
|
const asked = ask();
|
|
301
316
|
try {
|
|
302
317
|
const answer = await api.invite(asked.id, body);
|
|
303
|
-
if (!stillOpen(asked))
|
|
318
|
+
if (!stillOpen(asked)) {
|
|
319
|
+
lateInvite(asked, answer.participant?.name || body.name, answer.link);
|
|
320
|
+
return dropped(asked);
|
|
321
|
+
}
|
|
304
322
|
invite = { name: answer.participant?.name || body.name, link: answer.link, qr_svg: answer.qr_svg, lan: answer.lan,
|
|
305
323
|
warning: answer.warning || null, identity: answer.participant?.identity || null, copied: false };
|
|
306
324
|
error = null;
|
|
@@ -314,10 +332,27 @@ export function createParticipantsControl({ el, session, api, onChange = () => {
|
|
|
314
332
|
if (shell.isOpen()) draw();
|
|
315
333
|
}
|
|
316
334
|
|
|
335
|
+
// An invite answered after its panel closed: the grant exists and its link
|
|
336
|
+
// shows only once, so a sticky toast keeps it (Copy link; a failed copy
|
|
337
|
+
// shows the link itself to select), never a later panel.
|
|
338
|
+
function lateInvite(asked, name, link) {
|
|
339
|
+
if (!link) return;
|
|
340
|
+
const copy = async () => {
|
|
341
|
+
try {
|
|
342
|
+
await win.navigator.clipboard.writeText(link);
|
|
343
|
+
toast(`${name}'s review link copied`);
|
|
344
|
+
} catch {
|
|
345
|
+
toast(`${name}'s review link: ${link}`, null, { sticky: true });
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
toast(lateInviteText(name, asked.id), { label: "Copy link", run: copy }, { sticky: true });
|
|
349
|
+
}
|
|
350
|
+
|
|
317
351
|
async function submitCap(form) {
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
352
|
+
// A cleared field is no cap to set (Number("") would be a $0 cap).
|
|
353
|
+
const { value, error: problem } = capValue(form.elements.cap.value);
|
|
354
|
+
if (problem || value == null) {
|
|
355
|
+
error = CAP_ERROR;
|
|
321
356
|
return draw();
|
|
322
357
|
}
|
|
323
358
|
const asked = ask();
|
|
@@ -81,14 +81,15 @@ export async function subscribe({ reg, key, post }) {
|
|
|
81
81
|
return { subscription: sub, answer };
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
//
|
|
84
|
+
// Tells chi web to forget this device, then unsubscribes it: a DELETE
|
|
85
|
+
// that fails (a flaky connection) throws before anything local changes, so
|
|
86
|
+
// the bell stays on with its subscription and the next tap tries again.
|
|
85
87
|
// @param del (endpoint) => Promise
|
|
86
88
|
// @return whether there was a subscription
|
|
87
89
|
export async function unsubscribe({ reg, del }) {
|
|
88
90
|
const sub = await reg.pushManager.getSubscription();
|
|
89
91
|
if (!sub) return false;
|
|
90
|
-
|
|
92
|
+
await del(sub.endpoint);
|
|
91
93
|
await sub.unsubscribe();
|
|
92
|
-
await del(endpoint);
|
|
93
94
|
return true;
|
|
94
95
|
}
|
|
@@ -98,6 +98,17 @@ export function sheetTemplate(model) {
|
|
|
98
98
|
}<div class="pd-note">Approvals, questions, step limits, cards and failed turns; quiet while a chi web page is in front.</div>`;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// Puts every switch in +pop+ back to +model+: lit skips a .checked binding
|
|
102
|
+
// whose value is the one it last set, so a switch the app then refused
|
|
103
|
+
// (iOS's Don't Allow, a failed PATCH) would keep its clicked state.
|
|
104
|
+
export function syncSwitches(pop, model) {
|
|
105
|
+
const rows = [model.thisRow, ...model.others];
|
|
106
|
+
for (const input of pop.querySelectorAll("input[data-pd]")) {
|
|
107
|
+
if (input.dataset.pd === "this") input.checked = model.on;
|
|
108
|
+
else if (input.dataset.pd === "done") input.checked = rows.some((r) => r.id === input.dataset.id && r.done);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
101
112
|
// The sheet at the bell. +state+() the bell's state; +endpoint+() this
|
|
102
113
|
// device's push endpoint (a Promise; null without a subscription);
|
|
103
114
|
// +toggleThis+() the bell's subscribe / unsubscribe (resolves to its
|
|
@@ -111,14 +122,17 @@ export function createPushDevices({ button, api, state, endpoint, toggleThis, no
|
|
|
111
122
|
// Each load's turn: an older answer that arrives late is dropped.
|
|
112
123
|
let loads = 0;
|
|
113
124
|
|
|
114
|
-
const
|
|
115
|
-
|
|
125
|
+
const paint = () => {
|
|
126
|
+
const model = sheetModel({ state: state(), info, busy, error, now: now() });
|
|
127
|
+
render(sheetTemplate(model), shell.pop);
|
|
128
|
+
syncSwitches(shell.pop, model);
|
|
116
129
|
};
|
|
130
|
+
const draw = () => { if (shell.isOpen()) paint(); };
|
|
117
131
|
|
|
118
132
|
const shell = createChipPopover({
|
|
119
133
|
id: "pushDevices", className: "ctx-popover push-devices", width: 340, el: doc.createElement("span"),
|
|
120
134
|
anchor: "#notifyBtn", openers: "#notifyBtn", summary: () => ({}), sheet: true, label: "Notifications", doc, win,
|
|
121
|
-
show: () =>
|
|
135
|
+
show: () => paint(),
|
|
122
136
|
onClose: () => { error = ""; },
|
|
123
137
|
});
|
|
124
138
|
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// /api/history (the owner may open a link in the same browser, on the same
|
|
5
5
|
// origin: each grant's list and the owner's stay apart). Read and written
|
|
6
6
|
// through storage.js (guarded): a list that can't be read or written is just
|
|
7
|
-
// not there. A link that ends forgets its list
|
|
8
|
-
|
|
7
|
+
// not there. A link that ends forgets its list; one that ended while no page
|
|
8
|
+
// was open goes when the next link's page loads (pruneReviewHistory).
|
|
9
|
+
import { readStored, removeStored, storedKeys, writeStored } from "./storage.js";
|
|
9
10
|
|
|
10
11
|
export const REVIEW_HISTORY_PREFIX = "chi_review_history:";
|
|
11
12
|
const REVIEW_HISTORY_SESSION_PREFIX = "chi_review_history_session:";
|
|
@@ -58,3 +59,14 @@ export function forgetReviewHistory(grantId, sessionId) {
|
|
|
58
59
|
const key = reviewHistoryKey(grantId || remembered);
|
|
59
60
|
if (key) removeStored(key);
|
|
60
61
|
}
|
|
62
|
+
|
|
63
|
+
// A participant page's first read (+sessionId+, its grant +grantId+): every
|
|
64
|
+
// other grant's list and every other session's pointer go, so the lists of
|
|
65
|
+
// links that ended while no page was open don't stay for good. Without
|
|
66
|
+
// either id (the owner's view, an older server's data) nothing goes.
|
|
67
|
+
export function pruneReviewHistory(sessionId, grantId) {
|
|
68
|
+
if (!sessionId || !grantId) return;
|
|
69
|
+
const keep = new Set([reviewHistoryKey(grantId), `${REVIEW_HISTORY_SESSION_PREFIX}${sessionId}`]);
|
|
70
|
+
for (const key of storedKeys(REVIEW_HISTORY_PREFIX)) if (!keep.has(key)) removeStored(key);
|
|
71
|
+
for (const key of storedKeys(REVIEW_HISTORY_SESSION_PREFIX)) if (!keep.has(key)) removeStored(key);
|
|
72
|
+
}
|
|
@@ -19,10 +19,8 @@ export function readSubjects(text) {
|
|
|
19
19
|
return { subjects: [...new Set(found)], error: null };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export const parseSubjects = (text) => readSubjects(text).subjects;
|
|
23
|
-
|
|
24
22
|
// The files as the form shows them again: a path with a space, a comma or
|
|
25
|
-
// a quote in quotes, so
|
|
23
|
+
// a quote in quotes, so readSubjects reads it back whole.
|
|
26
24
|
export function formatSubjects(subjects) {
|
|
27
25
|
return (subjects || []).map((s) => (/[\s,"]/.test(s) ? `"${s}"` : s)).join(", ");
|
|
28
26
|
}
|
|
@@ -76,9 +76,21 @@ export function createSessionActions({
|
|
|
76
76
|
// +stopHolds+ is the sheet's "Stop all": the worker's stop-all step, then
|
|
77
77
|
// the stop. A delegate or a background task outlives the worker either way
|
|
78
78
|
// (docs/sessions.md, "Stopping a worker"), which is why the sheet says so.
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
// One Stop per session at a time (as reviewingIds): a second tap while the
|
|
80
|
+
// holds read (or the stop) is under way would be a second confirm.
|
|
81
|
+
const stopAsks = new Set();
|
|
81
82
|
|
|
83
|
+
async function stopSelected(id = selected(), options = {}) {
|
|
84
|
+
if (!id || stopAsks.has(id)) return;
|
|
85
|
+
stopAsks.add(id);
|
|
86
|
+
try {
|
|
87
|
+
await stopOne(id, options);
|
|
88
|
+
} finally {
|
|
89
|
+
stopAsks.delete(id);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function stopOne(id, { stopHolds = false }) {
|
|
82
94
|
if (!stopHolds) {
|
|
83
95
|
let holds = [];
|
|
84
96
|
try {
|
|
@@ -21,3 +21,19 @@ export function writeStored(key, value) {
|
|
|
21
21
|
export function removeStored(key) {
|
|
22
22
|
try { globalThis.localStorage.removeItem(key); } catch (_) {}
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
// The stored keys that start with +prefix+ ([] when they can't be read), a
|
|
26
|
+
// copy: removing one while walking them is safe.
|
|
27
|
+
export function storedKeys(prefix) {
|
|
28
|
+
try {
|
|
29
|
+
const storage = globalThis.localStorage;
|
|
30
|
+
const keys = [];
|
|
31
|
+
for (let i = 0; i < storage.length; i++) {
|
|
32
|
+
const key = storage.key(i);
|
|
33
|
+
if (typeof key === "string" && key.startsWith(prefix)) keys.push(key);
|
|
34
|
+
}
|
|
35
|
+
return keys;
|
|
36
|
+
} catch (_) {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -76,6 +76,11 @@ module Samagotchi
|
|
|
76
76
|
result_for(response, ms(started))
|
|
77
77
|
rescue *NETWORK_ERRORS => e
|
|
78
78
|
Result.new(status: :failed, code: nil, ms: ms(started), error: e.is_a?(Timeout::Error) ? "timeout" : e.class.name)
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
# Anything else is this device's failure too (a p256dh off the
|
|
81
|
+
# curve on disk: Crypto.encrypt raises), never the other devices'
|
|
82
|
+
# or the Notifier's flush. Its class, never its message.
|
|
83
|
+
Result.new(status: :failed, code: nil, ms: ms(started), error: e.class.name)
|
|
79
84
|
end
|
|
80
85
|
|
|
81
86
|
private
|
|
@@ -201,11 +201,14 @@ module Samagotchi
|
|
|
201
201
|
added
|
|
202
202
|
end
|
|
203
203
|
|
|
204
|
-
# Removes the subscription with this short id or endpoint
|
|
204
|
+
# Removes the subscription with this short id or endpoint, only one
|
|
205
|
+
# subscribed on +origin+ (nil: any; a push service's 404/410).
|
|
205
206
|
# @return [Sub, nil] the one removed
|
|
206
|
-
def remove(id_or_endpoint)
|
|
207
|
+
def remove(id_or_endpoint, origin: nil)
|
|
207
208
|
update do |subs|
|
|
208
|
-
gone = subs.find
|
|
209
|
+
gone = subs.find do |sub|
|
|
210
|
+
(sub.id == id_or_endpoint || sub.endpoint == id_or_endpoint) && (origin.nil? || sub.origin == origin)
|
|
211
|
+
end
|
|
209
212
|
[subs - [gone], gone]
|
|
210
213
|
end
|
|
211
214
|
end
|
|
@@ -307,6 +310,7 @@ module Samagotchi
|
|
|
307
310
|
unless p256dh&.bytesize == 65 && p256dh.getbyte(0) == 4
|
|
308
311
|
raise Invalid, "a push subscription's p256dh must be a P-256 public key (65 bytes, base64url)"
|
|
309
312
|
end
|
|
313
|
+
raise Invalid, "a push subscription's p256dh is not a point on P-256" unless point?(p256dh)
|
|
310
314
|
raise Invalid, "a push subscription's auth must be 16 bytes (base64url)" unless decoded(keys["auth"])&.bytesize == 16
|
|
311
315
|
|
|
312
316
|
[endpoint, keys["p256dh"], keys["auth"]]
|
|
@@ -331,6 +335,15 @@ module Samagotchi
|
|
|
331
335
|
@services.any? { |entry| entry.start_with?(".") ? host.end_with?(entry) && host.length > entry.length : host == entry }
|
|
332
336
|
end
|
|
333
337
|
|
|
338
|
+
# The key as Crypto.encrypt builds it: 65 bytes starting 0x04 can
|
|
339
|
+
# still lie off the curve (encrypt would raise on every push).
|
|
340
|
+
def point?(raw)
|
|
341
|
+
Crypto.ec_public(raw)
|
|
342
|
+
true
|
|
343
|
+
rescue ArgumentError, OpenSSL::OpenSSLError
|
|
344
|
+
false
|
|
345
|
+
end
|
|
346
|
+
|
|
334
347
|
def decoded(text)
|
|
335
348
|
Crypto.unb64u(text)
|
|
336
349
|
rescue ArgumentError
|
|
@@ -86,7 +86,8 @@ module Samagotchi
|
|
|
86
86
|
[204, { "Cache-Control" => "no-store" }, []]
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
-
# {endpoint} → 204: this device's bell turned off
|
|
89
|
+
# {endpoint} → 204: this device's bell turned off (only a device
|
|
90
|
+
# subscribed on this origin, as the other device routes).
|
|
90
91
|
def handle_push_unsubscribe(req)
|
|
91
92
|
return push_off unless @push
|
|
92
93
|
|
|
@@ -94,7 +95,7 @@ module Samagotchi
|
|
|
94
95
|
endpoint = body["endpoint"] if body.is_a?(Hash)
|
|
95
96
|
return error_response(400, "missing_fields", "endpoint is required") unless endpoint.is_a?(String) && !endpoint.empty?
|
|
96
97
|
|
|
97
|
-
gone = @push.store.remove(endpoint)
|
|
98
|
+
gone = @push.store.remove(endpoint, origin: @push.origin)
|
|
98
99
|
Log.info(:web, "push_unsubscribed", device: gone.id) if gone
|
|
99
100
|
[204, { "Cache-Control" => "no-store" }, []]
|
|
100
101
|
end
|
|
@@ -256,7 +256,8 @@ module Samagotchi
|
|
|
256
256
|
# timeout and a margin, on top of +wait+); 409
|
|
257
257
|
# owned_by_tui (a chi REPL has it), 409 scratch (a leftover scratch
|
|
258
258
|
# session, as chi --attach here says); 504 worker_timeout (its Bridge
|
|
259
|
-
# didn't come in time);
|
|
259
|
+
# didn't come in time); 409 worker_newer (a worker whose protocol is newer
|
|
260
|
+
# than this chi's, not resumed); 404 not_found.
|
|
260
261
|
def handle_wake(req, id)
|
|
261
262
|
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
262
263
|
wait = body.fetch("wait", WAKE_MAX_WAIT)
|
|
@@ -284,7 +285,9 @@ module Samagotchi
|
|
|
284
285
|
# rediscovery and its features after a restart, and before its wake
|
|
285
286
|
# whether the worker is leaving (its recap on leave: the wake waits
|
|
286
287
|
# for it, so the client says so). 200 {session_id, live, stopped,
|
|
287
|
-
# owner: worker|tui|null, leaving,
|
|
288
|
+
# owner: worker|tui|null, leaving, stuck (a worker holds the lock with
|
|
289
|
+
# no Bridge past WorkerLink::START_LIMIT), skew (its protocol is
|
|
290
|
+
# newer than this chi's), leave_seconds (its leave's budget
|
|
288
291
|
# left, nil unless leaving), worker: {epoch, started_at, version,
|
|
289
292
|
# features}|null, default_model (this machine's config, as the
|
|
290
293
|
# wake's), parent_strategy}; 404 not_found.
|
|
@@ -292,7 +295,7 @@ module Samagotchi
|
|
|
292
295
|
state = session_access.worker(id)
|
|
293
296
|
leaving = state.leaving?
|
|
294
297
|
json_response(200, { session_id: id, live: state.live?, stopped: state.stopped, owner: state.owner&.kind,
|
|
295
|
-
leaving: leaving, leave_seconds: (state.owner.leave_left.round(1) if leaving),
|
|
298
|
+
leaving: leaving, stuck: state.stuck?, skew: state.skew?, leave_seconds: (state.owner.leave_left.round(1) if leaving),
|
|
296
299
|
worker: state.sidecar && worker_json(state.sidecar),
|
|
297
300
|
default_model: ModelProfile.default_model_name,
|
|
298
301
|
parent_strategy: strategy_json(session_access.parent_strategy(id)) })
|
|
@@ -348,23 +351,28 @@ module Samagotchi
|
|
|
348
351
|
|
|
349
352
|
# GET /api/sessions/:id/stats: the live worker's metrics for /stats
|
|
350
353
|
# (its stats route; a worker from before it answers only its state's
|
|
351
|
-
# metrics). 200 {session_id, metrics}; 503 not_live, 504
|
|
352
|
-
#
|
|
354
|
+
# metrics). 200 {session_id, metrics}; 503 not_live, 504 worker_timeout,
|
|
355
|
+
# 502 bridge_secret or bridge_error.
|
|
353
356
|
def handle_stats(_req, id)
|
|
354
357
|
@session_class.load(id, state_dir: default_state_dir)
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
metrics = json.dig("session_state_snapshot", "metrics") if status == 200 && json.is_a?(Hash)
|
|
358
|
+
request = lambda do |client|
|
|
359
|
+
response = client.get_response("stats")
|
|
360
|
+
if response.status == 404 && response.json&.dig("error") != "unknown_session"
|
|
361
|
+
response = client.get_response("state")
|
|
362
|
+
end
|
|
363
|
+
response
|
|
362
364
|
end
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
365
|
+
with_message(relay(id, bridge_client(id), request, what: "the stats were not read", cant: "read stats") do |reply|
|
|
366
|
+
next unless reply.status == 200
|
|
367
|
+
|
|
368
|
+
json = reply.json
|
|
369
|
+
metrics = if json.is_a?(Hash) && json["metrics"]
|
|
370
|
+
json["metrics"]
|
|
371
|
+
else
|
|
372
|
+
json&.dig("session_state_snapshot", "metrics")
|
|
373
|
+
end
|
|
374
|
+
json_response(200, { session_id: id, metrics: metrics })
|
|
375
|
+
end)
|
|
368
376
|
end
|
|
369
377
|
|
|
370
378
|
# GET /api/sessions/:id/children/counts: the session's delegates'
|
|
@@ -28,7 +28,7 @@ module Samagotchi
|
|
|
28
28
|
# (request_toolkit, request_parent_answers, request_llm_context,
|
|
29
29
|
# request_thinking). Included in App: its ROUTES name handle_create, and
|
|
30
30
|
# its helpers (json_response, error_response, parse_json, request_body,
|
|
31
|
-
#
|
|
31
|
+
# await_worker_link, session_to_json, record_history, scope_dir, …) and
|
|
32
32
|
# ivars (@manager, @session_class, @state_dir, @hub) answer. See
|
|
33
33
|
# docs/architecture.md (chi web's routes).
|
|
34
34
|
module SessionCreate
|
|
@@ -130,7 +130,7 @@ module Samagotchi
|
|
|
130
130
|
end
|
|
131
131
|
# The worker's Bridge is the single live transport: wait (bounded) for
|
|
132
132
|
# it so the client can attach without client-side polling.
|
|
133
|
-
port =
|
|
133
|
+
port = await_worker_link(session.id).then { |link| link.live? ? link.port : nil }
|
|
134
134
|
# The projection holds it before the page hears back (no tick wait).
|
|
135
135
|
@hub&.touch(session.id)
|
|
136
136
|
record_history(body, prompt) if !idle && PromptHistory.shell_line?(prompt.to_s.strip)
|
|
@@ -172,7 +172,7 @@ module Samagotchi
|
|
|
172
172
|
model: model.empty? ? nil : model)
|
|
173
173
|
return error if error
|
|
174
174
|
|
|
175
|
-
port =
|
|
175
|
+
port = await_worker_link(session.id).then { |link| link.live? ? link.port : nil }
|
|
176
176
|
moved = session.moved_children || []
|
|
177
177
|
# The moved delegates' cards fold under the new link now.
|
|
178
178
|
[session.continues, session.id, *moved].each { |sid| @hub&.touch(sid) }
|
|
@@ -228,7 +228,7 @@ module Samagotchi
|
|
|
228
228
|
rescue ArgumentError => e
|
|
229
229
|
return error_response(404, "not_found", e.message)
|
|
230
230
|
end
|
|
231
|
-
port =
|
|
231
|
+
port = await_worker_link(session.id).then { |link| link.live? ? link.port : nil }
|
|
232
232
|
@hub&.touch(session.id)
|
|
233
233
|
json_response(201, session_to_json(session).merge(bridge_port: port, model_warning: session.model_warning))
|
|
234
234
|
end
|
|
@@ -7,7 +7,7 @@ require "time"
|
|
|
7
7
|
require_relative "../session"
|
|
8
8
|
require_relative "../session_manager"
|
|
9
9
|
require_relative "../bridge_client"
|
|
10
|
-
require_relative "../
|
|
10
|
+
require_relative "../worker_link"
|
|
11
11
|
require_relative "../log"
|
|
12
12
|
require_relative "../installed_versions"
|
|
13
13
|
require_relative "../version"
|
|
@@ -353,11 +353,13 @@ module Samagotchi
|
|
|
353
353
|
def live_stamp(owner, dir)
|
|
354
354
|
return nil unless owner
|
|
355
355
|
|
|
356
|
-
{ pid: owner.pid, kind: owner.kind, bridge_started_at: bridge_started_at(dir) }
|
|
356
|
+
{ pid: owner.pid, kind: owner.kind, bridge_started_at: bridge_started_at(dir, owner) }
|
|
357
357
|
end
|
|
358
358
|
|
|
359
|
-
|
|
360
|
-
|
|
359
|
+
# The Live worker's start (the owner already read is the one the link answers by).
|
|
360
|
+
def bridge_started_at(dir, owner)
|
|
361
|
+
link = WorkerLink.resolve(dir, unlink: false, owner: owner)
|
|
362
|
+
link.live? ? link.started_at : nil
|
|
361
363
|
end
|
|
362
364
|
|
|
363
365
|
def emit(type, data)
|
|
@@ -7,7 +7,7 @@ require_relative "../session_metrics"
|
|
|
7
7
|
require_relative "../llm_context_strategy"
|
|
8
8
|
require_relative "../host_registry"
|
|
9
9
|
require_relative "../bridge_client"
|
|
10
|
-
require_relative "../
|
|
10
|
+
require_relative "../worker_link"
|
|
11
11
|
require_relative "../bridge/pending_card"
|
|
12
12
|
|
|
13
13
|
module Samagotchi
|
|
@@ -37,8 +37,9 @@ module Samagotchi
|
|
|
37
37
|
def build(session, owner:, session_dir:, status: displayed_status(session, owner: owner), root_cache: nil,
|
|
38
38
|
registry: nil)
|
|
39
39
|
used = Array(session.used_memory_names)
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
link = WorkerLink.resolve(session_dir, unlink: false, owner: owner)
|
|
41
|
+
up = link.live?
|
|
42
|
+
sidecar = up ? link.sidecar : nil
|
|
42
43
|
saved = SessionMetrics.saved_summary(session_dir, budget_tokens: budget_resolver(session, registry))
|
|
43
44
|
recap_card = RecapStore.card(session_dir)
|
|
44
45
|
{
|
|
@@ -93,8 +94,9 @@ module Samagotchi
|
|
|
93
94
|
# Hidden from the strip and the list unless "include archived".
|
|
94
95
|
archived: ArchiveStore.archived?(session_dir),
|
|
95
96
|
project_root: session.project_root(cache: root_cache),
|
|
96
|
-
# A worker is reachable:
|
|
97
|
-
#
|
|
97
|
+
# A worker is reachable: WorkerLink answers Live (the lock held by
|
|
98
|
+
# a worker whose sidecar names its pid). A sidecar a dead worker
|
|
99
|
+
# left is not up.
|
|
98
100
|
bridge_up: up,
|
|
99
101
|
# The chi the live worker runs and what it can do (its sidecar;
|
|
100
102
|
# nil and [] without one): the page's stale-worker badge.
|
|
@@ -103,7 +105,7 @@ module Samagotchi
|
|
|
103
105
|
# For the tab's notifications (notify.js): an open question, the
|
|
104
106
|
# running turn's open card with actions, and how the last turn
|
|
105
107
|
# ended. A question saved by a worker that died is not open, nor a
|
|
106
|
-
# chi REPL's (
|
|
108
|
+
# chi REPL's (only a worker shares it).
|
|
107
109
|
pending_question: session.waiting_question(live: !!owner&.worker?),
|
|
108
110
|
pending_card: up ? Bridge::PendingCard.read(session_dir) : nil,
|
|
109
111
|
last_turn: session.last_turn&.to_file
|
|
@@ -129,16 +131,6 @@ module Samagotchi
|
|
|
129
131
|
owner ? session.status : Session::STATUS_IDLE
|
|
130
132
|
end
|
|
131
133
|
|
|
132
|
-
# @return [Boolean] the sidecar file is there and the session has a
|
|
133
|
-
# live owner (the file alone says nothing: the worker may be gone).
|
|
134
|
-
# The owner lock, not WorkerSidecar.live's port probe: a list builds
|
|
135
|
-
# this for every session, and the lock costs no connect.
|
|
136
|
-
def bridge_up?(session_dir, owner)
|
|
137
|
-
!owner.nil? && File.file?(WorkerSidecar.path(session_dir))
|
|
138
|
-
rescue StandardError
|
|
139
|
-
false
|
|
140
|
-
end
|
|
141
|
-
|
|
142
134
|
def first_preview_for(session)
|
|
143
135
|
raw = session.first_preview || session.last_prompt || ""
|
|
144
136
|
norm = raw.to_s.gsub(/\s+/, " ").strip
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -720,7 +720,7 @@ module Samagotchi
|
|
|
720
720
|
# ends the sender's wait on it (chi send --wait, chi web's /wait).
|
|
721
721
|
# @param origin [TurnOrigin] a participant's
|
|
722
722
|
def refuse_participant_turn(origin, reason)
|
|
723
|
-
TurnCheckpoint.record_refused(@session_dir, origin.enqueued_id, reason: reason)
|
|
723
|
+
TurnCheckpoint.record_refused(@session_dir, origin.enqueued_id, reason: reason, author: origin.participant_author)
|
|
724
724
|
Participants.log(@session_id, :turn, state_dir: @state_dir, participant: origin.participant_id,
|
|
725
725
|
enqueued_id: origin.enqueued_id, status: "refused", reason: reason)
|
|
726
726
|
@engine.announce(type: :turn_dequeued, enqueued_id: origin.enqueued_id, client_id: origin.client_id)
|