samagotchi 0.127.0 → 0.129.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 +106 -1
- data/docs/architecture.md +15 -5
- data/docs/cli.md +2 -1
- data/docs/guardrails.md +155 -25
- data/docs/internals/participants.md +23 -5
- data/docs/internals/web-push.md +4 -1
- data/docs/sessions.md +1 -1
- data/docs/testing.md +3 -2
- data/lib/samagotchi/bridge.rb +2 -0
- data/lib/samagotchi/bridge_client.rb +8 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +56 -0
- data/lib/samagotchi/guardrail_wiring.rb +6 -5
- data/lib/samagotchi/guardrails/child_boundary.rb +23 -13
- data/lib/samagotchi/guardrails/outside.rb +54 -5
- data/lib/samagotchi/guardrails/pair_boundary.rb +78 -26
- data/lib/samagotchi/guardrails/pair_command.rb +261 -66
- data/lib/samagotchi/guardrails/parent_approvals.rb +2 -1
- data/lib/samagotchi/guardrails/protected_paths.rb +98 -5
- data/lib/samagotchi/guardrails/read_only_shell.rb +239 -9
- data/lib/samagotchi/guardrails/review_paths.rb +22 -8
- data/lib/samagotchi/guardrails/rm_targets.rb +4 -3
- data/lib/samagotchi/guardrails/scratch_writes.rb +3 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +67 -35
- data/lib/samagotchi/guardrails/shell_lex.rb +1 -1
- data/lib/samagotchi/guardrails/shell_paths.rb +73 -25
- data/lib/samagotchi/guardrails/targets.rb +128 -24
- data/lib/samagotchi/session_manager.rb +13 -14
- data/lib/samagotchi/system_one/self_protect.rb +108 -16
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +9 -5
- data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +6 -12
- data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +23 -35
- data/lib/samagotchi/terminal_ui/attached_loop.rb +10 -18
- data/lib/samagotchi/terminal_ui/exit_lines.rb +2 -1
- data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
- data/lib/samagotchi/terminal_ui/own_turns.rb +81 -0
- data/lib/samagotchi/verify_anchor.rb +8 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +9 -7
- data/lib/samagotchi/web/participant_review.rb +4 -2
- data/lib/samagotchi/web/public/annotate_bar.js +14 -2
- data/lib/samagotchi/web/public/app.js +25 -5
- data/lib/samagotchi/web/public/composer.css +1 -0
- data/lib/samagotchi/web/public/notify.js +2 -1
- data/lib/samagotchi/web/public/participant.html +3 -2
- data/lib/samagotchi/web/public/participant.js +18 -7
- data/lib/samagotchi/web/public/participant_model.js +3 -2
- data/lib/samagotchi/web/public/participant_names.js +2 -2
- data/lib/samagotchi/web/public/question_card.js +4 -1
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/stream_handlers.js +4 -2
- data/lib/samagotchi/web/public/verdict_card.js +18 -3
- data/lib/samagotchi/web/public/verdict_fresh.js +23 -5
- data/lib/samagotchi/web/public/verify_card.css +1 -0
- data/lib/samagotchi/web/public/verify_preset.js +6 -0
- data/lib/samagotchi/web/verify_fresh_routes.rb +12 -7
- data/lib/samagotchi/worker.rb +82 -32
- data/lib/samagotchi/worker_idle_exit.rb +17 -0
- data/lib/samagotchi/worker_link.rb +13 -5
- metadata +2 -1
|
@@ -53,6 +53,7 @@ body.has-session.composer-folded #composeGrip::after{opacity:1;width:48px;margin
|
|
|
53
53
|
info bar's buttons while folded, the stage card's bottom in the stage view); it reaches 7 px below the edge, the info bar's
|
|
54
54
|
border and padding, so it never covers a button of the bar. */
|
|
55
55
|
@media(max-width:600px){
|
|
56
|
+
.composer-card-foot{padding-top:4px;padding-bottom:4px}
|
|
56
57
|
#composeGrip{width:96px;height:26px;margin:-19px auto -7px}
|
|
57
58
|
#composeGrip::after{top:17px;opacity:1}
|
|
58
59
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// title badge for what it returns, while the tab is not in front.
|
|
4
4
|
|
|
5
5
|
import { clip } from "./format.js";
|
|
6
|
+
import { isParticipantAuthor } from "./participant_model.js";
|
|
6
7
|
|
|
7
8
|
// The bell's state. OS notifications need a secure context: on chi web
|
|
8
9
|
// over http at a LAN address the API is missing or always denied, so the
|
|
@@ -164,7 +165,7 @@ export function attentionText(session, attention) {
|
|
|
164
165
|
const seconds = Math.round(Number(session?.last_turn?.seconds) || 0);
|
|
165
166
|
const verdict = session?.last_verdict;
|
|
166
167
|
// An owner's verdict has no participant author (none, "" or "owner").
|
|
167
|
-
const byParticipant =
|
|
168
|
+
const byParticipant = isParticipantAuthor(verdict?.by);
|
|
168
169
|
const who = verdict?.by_name || (byParticipant ? "A participant" : "You");
|
|
169
170
|
const verdictReason = clip(String(verdict?.reason || ""), VERDICT_REASON_MAX);
|
|
170
171
|
const verdictIcon = { confirmed: "✅", refuted: "❌", unclear: "❓" }[verdict?.verdict] || "";
|
|
@@ -122,9 +122,10 @@ main{flex:1;min-height:0;overflow-y:auto;padding:16px 16px 8px}
|
|
|
122
122
|
#composer textarea{font:inherit;line-height:1.45;padding:2px 0}
|
|
123
123
|
#composer textarea:disabled{cursor:not-allowed}
|
|
124
124
|
#composer .actions-col #send{height:30px;min-height:30px;line-height:30px;padding:0 16px;font-weight:600}
|
|
125
|
+
/* The key hint wraps to at most two lines (a narrow card cuts the second's end); the whole text is its title. */
|
|
126
|
+
.p-hint.composer-line{color:var(--text-faint);height:auto;max-height:36px;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2}
|
|
125
127
|
/* The participant's own usage (participant_model.js usageItems: the fields their grant's exposure shows), one line in
|
|
126
128
|
the card's footer, the end cut. Folded, it stays under the grip. */
|
|
127
|
-
.p-hint.composer-line{color:var(--text-faint);white-space:nowrap;text-overflow:ellipsis}
|
|
128
129
|
.p-foot{min-width:0}
|
|
129
130
|
.p-foot:has(> .p-usage[hidden]){display:none}
|
|
130
131
|
/* Folded before the usage loads, the card keeps a footer's height under its grip, not a 2 px sliver. */
|
|
@@ -151,7 +152,7 @@ button.small{font-size:12px;padding:2px 8px}
|
|
|
151
152
|
.p-people .p-person:nth-child(n+4){display:none}
|
|
152
153
|
main{padding:12px 16px 6px}
|
|
153
154
|
.p-bubble{padding:9px 12px}
|
|
154
|
-
.p-hint,.p-note{display:none}
|
|
155
|
+
.p-hint.composer-line,.p-note{display:none}
|
|
155
156
|
}
|
|
156
157
|
/* Under 16px iOS Safari zooms the page on focus (and a Home Screen app never zooms back). */
|
|
157
158
|
@media (max-width:600px),(pointer:coarse){#composer textarea{font-size:16px}}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// (spend vs cap, queue, link expiry, model). The owner's participant view
|
|
13
13
|
// mounts none.
|
|
14
14
|
import {
|
|
15
|
-
authorColor, participantAuthor, authorLabel, toolLine, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
|
|
15
|
+
authorColor, participantAuthor, isParticipantAuthor, authorLabel, toolLine, refusalText, dequeueText, endedText, streamURL, reloads, endsLiveTurn, liveFrom, applyLive,
|
|
16
16
|
liveText, withToolSteps, pickSubject, subjectBody, subjectsKey, narrowView, retryDelay, retries, threadGaps, usageItems,
|
|
17
17
|
modeCopy,
|
|
18
18
|
} from "./participant_model.js";
|
|
@@ -22,10 +22,11 @@ import { createAuthLost, lostAnswer } from "./auth_lost.js";
|
|
|
22
22
|
import { REPLACE_SESSION_TEXT, fragmentCode, tradeFragmentCode } from "./token_fragment.js";
|
|
23
23
|
import { createAnnotateBar } from "./annotate_bar.js";
|
|
24
24
|
import { parsePresets } from "./annotate_presets.js";
|
|
25
|
-
import { VERIFY_PRESET, withVerify } from "./verify_preset.js";
|
|
25
|
+
import { VERIFY_PRESET, VERIFY_UNAVAILABLE, withVerify } from "./verify_preset.js";
|
|
26
26
|
import { appendQuote } from "./annotations.js";
|
|
27
27
|
import { messageAuthorData, participantQuote, participantSource } from "./participant_annotate.js";
|
|
28
28
|
import { claimFromHash, selectionClaim, showClaim } from "./claim_link.js";
|
|
29
|
+
import { CHECK_NOT_ON_PAGE } from "./verdict_card.js";
|
|
29
30
|
import { freshURL, placeVerdictWithFresh, settleRunning } from "./verdict_fresh.js";
|
|
30
31
|
import { mountComposer } from "./composer_mount.js";
|
|
31
32
|
import { PROMPT_LINE_PX } from "./composer_size.js";
|
|
@@ -234,7 +235,7 @@ function render() {
|
|
|
234
235
|
const copy = modeCopy(viewer.mode);
|
|
235
236
|
$("note").textContent = ownerView ? "" : copy.note;
|
|
236
237
|
$("hint").textContent = `⏎ sends, ⇧⏎ a new line, ↑ an earlier comment. ${copy.hint}`;
|
|
237
|
-
$("hint").title = $("hint").textContent; //
|
|
238
|
+
$("hint").title = $("hint").textContent; // at most two lines in the card: the whole text on hover
|
|
238
239
|
$("head").hidden = false;
|
|
239
240
|
$("main").hidden = false;
|
|
240
241
|
renderPlan();
|
|
@@ -343,7 +344,7 @@ function messageNode(message) {
|
|
|
343
344
|
const who = el("div", "p-who");
|
|
344
345
|
who.append(el("b", "", authorLabel(message)));
|
|
345
346
|
if (message.merged) who.append(el("span", "p-tag", "added mid-turn"));
|
|
346
|
-
if (message.author) who.append(el("span", "p-tag", modeCopy(message.participant_mode).tag));
|
|
347
|
+
if (isParticipantAuthor(message.author)) who.append(el("span", "p-tag", modeCopy(message.participant_mode).tag));
|
|
347
348
|
node.append(who);
|
|
348
349
|
const text = message.content + (message.images ? `\n[${message.images} image${message.images > 1 ? "s" : ""}]` : "");
|
|
349
350
|
node.append(el("div", "p-bubble", text));
|
|
@@ -413,13 +414,17 @@ function renderThread() {
|
|
|
413
414
|
const author = record.by || null;
|
|
414
415
|
const viewer = data.viewer || {};
|
|
415
416
|
let who = ownerView ? "You" : "Owner";
|
|
416
|
-
if (author
|
|
417
|
+
if (isParticipantAuthor(author)) {
|
|
417
418
|
if (!ownerView && author === participantAuthor(viewer.id)) who = "You";
|
|
418
419
|
else {
|
|
419
420
|
who = (data.participants || []).find((participant) => participantAuthor(participant.id) === author)?.name || "A participant";
|
|
420
421
|
}
|
|
421
422
|
}
|
|
422
|
-
placeVerdictWithFresh(thread, record, {
|
|
423
|
+
placeVerdictWithFresh(thread, record, {
|
|
424
|
+
who,
|
|
425
|
+
author,
|
|
426
|
+
onMissingCheck: () => showClaimNote(CHECK_NOT_ON_PAGE),
|
|
427
|
+
}, verifyFreshState());
|
|
423
428
|
}
|
|
424
429
|
if (atBottom || !thread.dataset.drawn) main.scrollTop = main.scrollHeight;
|
|
425
430
|
thread.dataset.drawn = "1";
|
|
@@ -429,12 +434,16 @@ function renderThread() {
|
|
|
429
434
|
// participant starts one while their grant could run a turn now
|
|
430
435
|
// (can_comment); the owner looking through the participant view never
|
|
431
436
|
// does, and no one here gets a link to the child's session.
|
|
437
|
+
const freshErrors = {};
|
|
432
438
|
function verifyFreshState() {
|
|
433
439
|
if (!Array.isArray(data.fresh_running)) data.fresh_running = [];
|
|
440
|
+
const unavailable = data.session?.verify_unavailable === true;
|
|
434
441
|
return {
|
|
435
442
|
verdicts: data.verdicts || [],
|
|
436
443
|
running: data.fresh_running,
|
|
437
|
-
|
|
444
|
+
errors: freshErrors,
|
|
445
|
+
canStart: !unavailable && !ownerView && !ended && !!data.viewer?.can_comment,
|
|
446
|
+
unavailableReason: unavailable ? VERIFY_UNAVAILABLE : null,
|
|
438
447
|
start: async (of) => {
|
|
439
448
|
const { ok, body } = await api(freshURL(sessionId, of), {
|
|
440
449
|
method: "POST", body: "{}", headers: { "Content-Type": "application/json" },
|
|
@@ -650,6 +659,8 @@ for (const button of $("viewSeg").querySelectorAll("button")) {
|
|
|
650
659
|
// while this participant can't comment.
|
|
651
660
|
const annotateBar = createAnnotateBar({
|
|
652
661
|
presets: withVerify(parsePresets(body.annotatePresets)),
|
|
662
|
+
disabledPreset: (preset) => preset === VERIFY_PRESET
|
|
663
|
+
&& data?.session?.verify_unavailable === true ? VERIFY_UNAVAILABLE : null,
|
|
653
664
|
canAnnotate: () => !ownerView && !ended && !!data?.viewer?.can_comment,
|
|
654
665
|
roots: () => [$("planDoc"), $("thread")],
|
|
655
666
|
isLive: (root) => !!root.closest?.(".p-msg.live"),
|
|
@@ -9,6 +9,7 @@ import { fullTime, gapText } from "./format.js";
|
|
|
9
9
|
// (Steer.author_for / PARTICIPANT_PREFIX): the one place the web spells it.
|
|
10
10
|
export const PARTICIPANT_PREFIX = "p:";
|
|
11
11
|
export const participantAuthor = (id) => `${PARTICIPANT_PREFIX}${id}`;
|
|
12
|
+
export const isParticipantAuthor = (by) => typeof by === "string" && by.startsWith(PARTICIPANT_PREFIX);
|
|
12
13
|
|
|
13
14
|
// Six author colours (tokens.css --author-1..6), one per participant id,
|
|
14
15
|
// the same on every page that shows them (the owner's badges too).
|
|
@@ -22,13 +23,13 @@ export function authorSlot(author) {
|
|
|
22
23
|
|
|
23
24
|
// The CSS colour of an author: a participant's slot, else the owner's.
|
|
24
25
|
export function authorColor(author) {
|
|
25
|
-
return author ? `var(--author-${authorSlot(author)})` : "var(--author-owner)";
|
|
26
|
+
return isParticipantAuthor(author) ? `var(--author-${authorSlot(author)})` : "var(--author-owner)";
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
// Who wrote a user message or started a turn, as the page names them.
|
|
29
30
|
export function authorLabel(item) {
|
|
30
31
|
if (item?.mine) return "You";
|
|
31
|
-
if (item?.author) return item.author_name || "A participant";
|
|
32
|
+
if (isParticipantAuthor(item?.author)) return item.author_name || "A participant";
|
|
32
33
|
if (item?.automatic) return "Automatic";
|
|
33
34
|
return "Owner";
|
|
34
35
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// prompt's own author_name (a saved message, turn_started) comes first; a
|
|
5
5
|
// badge drawn before the list came, with neither, says UNNAMED until
|
|
6
6
|
// nameUnnamedBadges names it.
|
|
7
|
-
import { authorColor, participantAuthor } from "./participant_model.js";
|
|
7
|
+
import { authorColor, isParticipantAuthor, participantAuthor } from "./participant_model.js";
|
|
8
8
|
|
|
9
9
|
export const UNNAMED = "a participant";
|
|
10
10
|
|
|
@@ -28,7 +28,7 @@ export function participantName(author) {
|
|
|
28
28
|
// +mode+: the turn's participant_mode stamp ("review", "pair"; the
|
|
29
29
|
// message's, or turn_started's), kept as mode: when known.
|
|
30
30
|
export function authorBadge(author, name = null, mode = null) {
|
|
31
|
-
if (!author
|
|
31
|
+
if (!isParticipantAuthor(author)) return null;
|
|
32
32
|
const known = name || participantName(author);
|
|
33
33
|
return { author, name: known || UNNAMED, color: authorColor(author), ...(known ? {} : { unnamed: true }), ...(mode ? { mode } : {}) };
|
|
34
34
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// command or paths, where and why. Its options end in "Deny"; the answer's
|
|
4
4
|
// last option denies. A page that doesn't know approvals still shows the
|
|
5
5
|
// plain question text, which says the same.
|
|
6
|
+
import { isParticipantAuthor, PARTICIPANT_PREFIX } from "./participant_model.js";
|
|
6
7
|
|
|
7
8
|
export function isApproval(pq) {
|
|
8
9
|
return !!pq && pq.kind === "approval";
|
|
@@ -68,7 +69,9 @@ export function relayView(pq) {
|
|
|
68
69
|
export function approvalAsker(pq) {
|
|
69
70
|
const a = (pq && pq.approval) || {};
|
|
70
71
|
if (!a.author) return null;
|
|
71
|
-
|
|
72
|
+
const author = String(a.author);
|
|
73
|
+
return a.author_name ? String(a.author_name)
|
|
74
|
+
: isParticipantAuthor(author) ? author.slice(PARTICIPANT_PREFIX.length) : author;
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
// @return {null | {tool, what, isCommand, where, why, paths?, preview?, delegate?, asker?}};
|
|
@@ -53,6 +53,7 @@ export function emptySessionState() {
|
|
|
53
53
|
// worker_version / worker_features): the info bar's stale-worker badge.
|
|
54
54
|
workerVersion: null,
|
|
55
55
|
workerFeatures: [],
|
|
56
|
+
bridgeUp: false,
|
|
56
57
|
// The LLM context strategy, apply rule and budget the next turn runs
|
|
57
58
|
// under, each with its source (the info bar's llm ctx chip), or null.
|
|
58
59
|
llmContext: null,
|
|
@@ -95,6 +96,7 @@ export function sessionStateFrom(data) {
|
|
|
95
96
|
// not at the session's next hub event (followSelectedSession).
|
|
96
97
|
workerVersion: s.worker_version || null,
|
|
97
98
|
workerFeatures: Array.isArray(s.worker_features) ? s.worker_features : [],
|
|
99
|
+
bridgeUp: s.bridge_up === true,
|
|
98
100
|
timing: normalizeTiming(data.timing),
|
|
99
101
|
};
|
|
100
102
|
// The saved fill, so a reloaded or stopped session shows it before its
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
import { commandView, drawCommandBubble, historyHasCommandBubble } from "./commands.js";
|
|
56
56
|
import { keepEarlyRestore, restoreAction } from "./restore.js";
|
|
57
57
|
import { PAIR, setBadgeMode } from "./participant_names.js";
|
|
58
|
+
import { isParticipantAuthor } from "./participant_model.js";
|
|
58
59
|
|
|
59
60
|
export function createStreamHandlers(deps) {
|
|
60
61
|
const {
|
|
@@ -163,12 +164,13 @@ export function createStreamHandlers(deps) {
|
|
|
163
164
|
// A participant's review turn: its id and author, for Apply at its end
|
|
164
165
|
// (turn_completed names neither). A pair turn acted itself: no Apply.
|
|
165
166
|
const author = data.author || data.origin?.author;
|
|
167
|
+
const participant = isParticipantAuthor(author);
|
|
166
168
|
const pair = data.participant_mode === PAIR;
|
|
167
|
-
liveTurn.review =
|
|
169
|
+
liveTurn.review = participant && data.turn_id && !pair ? { turn_id: data.turn_id, author, author_name: data.author_name } : null;
|
|
168
170
|
// Its prompt drawn while queued takes the turn's mode now.
|
|
169
171
|
if (data.participant_mode) setBadgeMode(promptBubble, data.participant_mode);
|
|
170
172
|
// The owner's input queues behind it (the composer's delivery line).
|
|
171
|
-
liveTurn.participantTurn =
|
|
173
|
+
liveTurn.participantTurn = participant;
|
|
172
174
|
setTurnRunning(true);
|
|
173
175
|
setLiveStatus("running");
|
|
174
176
|
liveTurn.timing.detach();
|
|
@@ -1,10 +1,23 @@
|
|
|
1
|
+
import { ownerClaimHash } from "./claim_link.js";
|
|
2
|
+
|
|
1
3
|
const TYPES = Object.freeze({
|
|
2
4
|
confirmed: { icon: "✅", label: "confirmed" },
|
|
3
5
|
refuted: { icon: "❌", label: "refuted" },
|
|
4
6
|
unclear: { icon: "❓", label: "unclear" },
|
|
5
7
|
});
|
|
6
8
|
|
|
7
|
-
export
|
|
9
|
+
export const CHECK_NOT_ON_PAGE = "That check is no longer on this page.";
|
|
10
|
+
|
|
11
|
+
export function ownerCheckHash(sessions, currentSessionId, checkTurnId) {
|
|
12
|
+
if (!Array.isArray(sessions) || typeof checkTurnId !== "string") return null;
|
|
13
|
+
const session = sessions.find((candidate) =>
|
|
14
|
+
candidate?.id !== currentSessionId
|
|
15
|
+
&& Array.isArray(candidate.verdicts)
|
|
16
|
+
&& candidate.verdicts.some((verdict) => verdict?.check_turn_id === checkTurnId));
|
|
17
|
+
return session ? ownerClaimHash(session.id, { turnId: checkTurnId }) : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function placeVerdict(root, record, { who = "Owner", author = null, onMissingCheck = null } = {}) {
|
|
8
21
|
const anchor = record?.anchor;
|
|
9
22
|
const type = TYPES[record?.verdict];
|
|
10
23
|
if (!root || !type || typeof anchor?.turn_id !== "string" || typeof anchor?.quote !== "string"
|
|
@@ -47,8 +60,10 @@ export function placeVerdict(root, record, { who = "Owner", author = null } = {}
|
|
|
47
60
|
link.textContent = "View check";
|
|
48
61
|
link.addEventListener("click", (event) => {
|
|
49
62
|
event.preventDefault();
|
|
50
|
-
[...root.querySelectorAll("[data-turn-id]")]
|
|
51
|
-
|
|
63
|
+
const check = [...root.querySelectorAll("[data-turn-id]")]
|
|
64
|
+
.find((element) => element.dataset.turnId === record.check_turn_id);
|
|
65
|
+
if (check) check.scrollIntoView?.({ behavior: "smooth", block: "center" });
|
|
66
|
+
else onMissingCheck?.(record);
|
|
52
67
|
});
|
|
53
68
|
card.append(heading, quote, reason, link);
|
|
54
69
|
|
|
@@ -29,7 +29,8 @@ export function settleRunning(running, record) {
|
|
|
29
29
|
// Places a session verdict as the pages draw them: an original verdict's
|
|
30
30
|
// card (verdict_card.js) with its Verify fresh part, or a fresh verdict on
|
|
31
31
|
// its original's card. +fresh+: {verdicts (all of the session's), running
|
|
32
|
-
// (the page's own array; a start adds to it),
|
|
32
|
+
// (the page's own array; a start adds to it), errors (the page's own
|
|
33
|
+
// object, check id → the last start error, so a redraw shows it again), canStart, start(of) → Promise
|
|
33
34
|
// of {ok, child_id, error}, childHref(id) → a link to the child or null}.
|
|
34
35
|
export function placeVerdictWithFresh(root, record, opts = {}, fresh = {}) {
|
|
35
36
|
if (record?.fresh) {
|
|
@@ -43,6 +44,11 @@ export function placeVerdictWithFresh(root, record, opts = {}, fresh = {}) {
|
|
|
43
44
|
return card;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
// Every verdict card under +root+ drawn again from +fresh+ (the gate changed).
|
|
48
|
+
export function redrawFresh(root, fresh = {}) {
|
|
49
|
+
for (const card of root?.querySelectorAll?.(".verify-verdict") || []) renderFresh(card, fresh);
|
|
50
|
+
}
|
|
51
|
+
|
|
46
52
|
// The card's Verify fresh part, drawn again from +fresh+: the fresh
|
|
47
53
|
// verdicts, the running check, else the button (an unclear verdict, and a
|
|
48
54
|
// viewer who could run a turn now).
|
|
@@ -79,6 +85,17 @@ export function renderFresh(card, fresh = {}) {
|
|
|
79
85
|
const link = childLink(typeof run === "string" ? null : run.child_id);
|
|
80
86
|
if (link) line.append(" ", link);
|
|
81
87
|
part.append(line);
|
|
88
|
+
} else if (card.classList.contains("unclear") && fresh.unavailableReason) {
|
|
89
|
+
const button = doc.createElement("button");
|
|
90
|
+
button.type = "button";
|
|
91
|
+
button.className = "verify-fresh-btn";
|
|
92
|
+
button.textContent = "Verify fresh";
|
|
93
|
+
button.title = fresh.unavailableReason;
|
|
94
|
+
button.disabled = true;
|
|
95
|
+
const note = doc.createElement("p");
|
|
96
|
+
note.className = "verify-fresh-unavailable";
|
|
97
|
+
note.textContent = fresh.unavailableReason;
|
|
98
|
+
part.append(button, note);
|
|
82
99
|
} else if (card.classList.contains("unclear") && fresh.canStart && fresh.start) {
|
|
83
100
|
const button = doc.createElement("button");
|
|
84
101
|
button.type = "button";
|
|
@@ -89,10 +106,11 @@ export function renderFresh(card, fresh = {}) {
|
|
|
89
106
|
part.append(button);
|
|
90
107
|
}
|
|
91
108
|
|
|
92
|
-
|
|
109
|
+
const error = (fresh.errors ||= {})[of];
|
|
110
|
+
if (error) {
|
|
93
111
|
const line = doc.createElement("p");
|
|
94
112
|
line.className = "verify-fresh-error";
|
|
95
|
-
line.textContent =
|
|
113
|
+
line.textContent = error;
|
|
96
114
|
part.append(line);
|
|
97
115
|
}
|
|
98
116
|
if (part.childNodes.length) card.append(part);
|
|
@@ -109,12 +127,12 @@ async function startFresh(card, button, fresh) {
|
|
|
109
127
|
result = { ok: false, error: error?.message };
|
|
110
128
|
}
|
|
111
129
|
if (result?.ok) {
|
|
112
|
-
delete
|
|
130
|
+
if (fresh.errors) delete fresh.errors[of];
|
|
113
131
|
if (fresh.running && !fresh.running.some((entry) => runningOf(entry) === of)) {
|
|
114
132
|
fresh.running.push(result.child_id ? { of, child_id: result.child_id } : of);
|
|
115
133
|
}
|
|
116
134
|
} else {
|
|
117
|
-
|
|
135
|
+
(fresh.errors ||= {})[of] = result?.error || "Couldn't start a fresh check.";
|
|
118
136
|
}
|
|
119
137
|
renderFresh(card, fresh);
|
|
120
138
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
.verify-fresh-result{font-weight:600;color:var(--text-bright)}
|
|
12
12
|
.verify-fresh-running{color:var(--text-muted);font-style:italic}
|
|
13
13
|
.verify-fresh-error{color:var(--danger-text)}
|
|
14
|
+
.verify-fresh-unavailable{color:var(--text-muted)}
|
|
14
15
|
.verify-fresh-link{color:var(--accent);font-weight:400}
|
|
15
16
|
.verify-fresh-btn{font:inherit;font-size:12px;font-weight:600;padding:3px 12px;border-radius:8px;cursor:pointer;background:var(--accent-bg);color:var(--accent);border:1px solid var(--accent-border)}
|
|
16
17
|
.verify-fresh-btn:hover{border-color:var(--accent)}
|
|
@@ -12,6 +12,12 @@ export const VERIFY_PRESET = Object.freeze({
|
|
|
12
12
|
+ "End with a line `Verdict: confirmed|refuted|unclear — <one-line reason>`.",
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
+
export const VERIFY_UNAVAILABLE = "restart this session to use Verify";
|
|
16
|
+
|
|
17
|
+
export function verifyWorkerUnavailable(workerFeatures, bridgeUp) {
|
|
18
|
+
return bridgeUp === true && Array.isArray(workerFeatures) && !workerFeatures.includes("verify");
|
|
19
|
+
}
|
|
20
|
+
|
|
15
21
|
// +presets+ (annotate_presets.js parsePresets) with Verify after them.
|
|
16
22
|
export function withVerify(presets) {
|
|
17
23
|
return [...(presets || []), VERIFY_PRESET];
|
|
@@ -33,14 +33,18 @@ module Samagotchi
|
|
|
33
33
|
def handle_verify_fresh(req, id, of)
|
|
34
34
|
clicker = principal(req).owner? ? nil : principal(req).grant
|
|
35
35
|
session = @session_class.load(id, state_dir: default_state_dir)
|
|
36
|
-
if clicker && (refused = participant_cap_refusal(clicker) || fresh_worker_refusal(session))
|
|
36
|
+
if clicker && (refused = participant_cap_refusal(clicker) || fresh_worker_refusal(session, participant: true))
|
|
37
37
|
return verify_fresh_refused(clicker, of, *refused)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
by = Steer.sender_author(field_of(VerifyFresh.original(session.verdicts, of), :by))
|
|
41
41
|
runner = by ? verdict_author_grant(id, by) : clicker
|
|
42
42
|
return author_link_ended(clicker, id, of, by) if by && runner.nil?
|
|
43
|
-
if runner && runner != clicker && (refused = participant_cap_refusal(runner) ||
|
|
43
|
+
if runner && runner != clicker && (refused = participant_cap_refusal(runner) ||
|
|
44
|
+
fresh_worker_refusal(session, participant: true))
|
|
45
|
+
return verify_fresh_refused(clicker, of, *refused, runner: runner)
|
|
46
|
+
end
|
|
47
|
+
if (refused = fresh_worker_refusal(session))
|
|
44
48
|
return verify_fresh_refused(clicker, of, *refused, runner: runner)
|
|
45
49
|
end
|
|
46
50
|
|
|
@@ -76,12 +80,13 @@ module Samagotchi
|
|
|
76
80
|
|
|
77
81
|
def field_of(record, key) = record && (record[key] || record[key.to_s])
|
|
78
82
|
|
|
79
|
-
# A live worker
|
|
80
|
-
#
|
|
81
|
-
|
|
82
|
-
def fresh_worker_refusal(session)
|
|
83
|
+
# A live worker must support VerifyFresh's parent checks. For a
|
|
84
|
+
# participant's child it must also have the review boundary.
|
|
85
|
+
def fresh_worker_refusal(session, participant: false)
|
|
83
86
|
features = ParticipantMode.live_features(session, default_state_dir)
|
|
84
|
-
return nil if features.nil?
|
|
87
|
+
return nil if features.nil?
|
|
88
|
+
return [409, VerifyAnchor::UNAVAILABLE["error"], VerifyAnchor::UNAVAILABLE["detail"]] if VerifyAnchor.worker_unavailable?(features)
|
|
89
|
+
return nil unless participant && !features.include?(Delivery::REVIEW_FEATURE)
|
|
85
90
|
|
|
86
91
|
[409, Delivery::REVIEW_UNAVAILABLE["error"], Delivery::REVIEW_UNAVAILABLE["detail"]]
|
|
87
92
|
end
|
data/lib/samagotchi/worker.rb
CHANGED
|
@@ -168,6 +168,10 @@ module Samagotchi
|
|
|
168
168
|
@loop_started = false
|
|
169
169
|
@left_loop = false
|
|
170
170
|
@signal_mutex = Mutex.new
|
|
171
|
+
# The exit being taken or taken (:exit, :delete, :restart), claimed
|
|
172
|
+
# under its own lock before #exit_request's slow stop-all (#claim_exit).
|
|
173
|
+
@exit_claim = nil
|
|
174
|
+
@exit_claim_mutex = Mutex.new
|
|
171
175
|
@grace_expired = false
|
|
172
176
|
# The running turn has ended (its end event) and its after_turn hooks
|
|
173
177
|
# run: a signal's grace waits for them (#signalled).
|
|
@@ -1583,41 +1587,89 @@ module Samagotchi
|
|
|
1583
1587
|
def exit_request(client_id, delete: false, restart: false, stop: false, ask: true)
|
|
1584
1588
|
# The Bridge serves before the idle-exit policy exists.
|
|
1585
1589
|
return WorkerIdleExit::Hold.new(reason: :starting) unless @idle_exit
|
|
1586
|
-
# One exit is already taken: the first one decides how the worker
|
|
1587
|
-
# leaves (a later stop-all can't turn a restart, whose reminders go to
|
|
1588
|
-
# the next worker, into a plain exit). Held at the leave, it is
|
|
1589
|
-
# forgotten and the next request counts again.
|
|
1590
|
-
return nil if @exit_requested
|
|
1591
1590
|
|
|
1592
|
-
if restart
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
recheck = @idle_exit.request_hold(requester: client_id, hint: TASK_STOP_HINT)
|
|
1610
|
-
return recheck if recheck
|
|
1611
|
-
end
|
|
1591
|
+
kind = if restart then :restart
|
|
1592
|
+
elsif delete then :delete
|
|
1593
|
+
else :exit
|
|
1594
|
+
end
|
|
1595
|
+
# One exit at a time, and the first one taken decides how the worker
|
|
1596
|
+
# leaves (a later stop-all can't turn a restart, whose reminders go to
|
|
1597
|
+
# the next worker, into a plain exit): claimed before the slow
|
|
1598
|
+
# stop-all, so a request of another kind meanwhile is refused (409),
|
|
1599
|
+
# not told it went through. Held at the leave, it is forgotten and the
|
|
1600
|
+
# next request counts again.
|
|
1601
|
+
refused = claim_exit(kind)
|
|
1602
|
+
return refused.value if refused
|
|
1603
|
+
|
|
1604
|
+
hold = exit_hold(client_id, kind: kind, stop: stop, ask: ask)
|
|
1605
|
+
if hold
|
|
1606
|
+
forget_exit
|
|
1607
|
+
return hold
|
|
1612
1608
|
end
|
|
1613
1609
|
|
|
1614
1610
|
@stopped_holds = nil unless stop
|
|
1615
|
-
@exit_requested = true
|
|
1616
1611
|
@exit_requested_by = client_id
|
|
1617
|
-
@exit_deletes =
|
|
1618
|
-
@exit_restart = restart
|
|
1612
|
+
@exit_deletes = kind == :delete
|
|
1613
|
+
@exit_restart = kind == :restart
|
|
1614
|
+
@exit_requested = true
|
|
1619
1615
|
@waker.wake
|
|
1620
1616
|
nil
|
|
1617
|
+
rescue StandardError
|
|
1618
|
+
# A look or a stop that raised lets the claim go (the Bridge answers 500).
|
|
1619
|
+
forget_exit unless @exit_requested
|
|
1620
|
+
raise
|
|
1621
|
+
end
|
|
1622
|
+
|
|
1623
|
+
# One answer for an exit request that doesn't get the claim, boxed so a
|
|
1624
|
+
# nil (accepted) is told apart from "claimed".
|
|
1625
|
+
ExitRefused = Data.define(:value)
|
|
1626
|
+
|
|
1627
|
+
# @return [ExitRefused, nil] nil when this request now holds the claim;
|
|
1628
|
+
# else its answer: nil (accepted: an exit of the same kind is already
|
|
1629
|
+
# taken) or WorkerIdleExit.leaving (one of another kind is, or is still
|
|
1630
|
+
# being taken)
|
|
1631
|
+
def claim_exit(kind)
|
|
1632
|
+
@exit_claim_mutex.synchronize do
|
|
1633
|
+
next ExitRefused.new(value: nil) if @exit_claim == kind && @exit_requested
|
|
1634
|
+
next ExitRefused.new(value: WorkerIdleExit.leaving(@exit_claim)) if @exit_claim
|
|
1635
|
+
|
|
1636
|
+
@exit_claim = kind
|
|
1637
|
+
nil
|
|
1638
|
+
end
|
|
1639
|
+
end
|
|
1640
|
+
|
|
1641
|
+
# The taken exit is let go: held at the leave, or its hand-off failed.
|
|
1642
|
+
def forget_exit
|
|
1643
|
+
@exit_claim_mutex.synchronize do
|
|
1644
|
+
@exit_requested = nil
|
|
1645
|
+
@exit_restart = false
|
|
1646
|
+
@exit_claim = nil
|
|
1647
|
+
end
|
|
1648
|
+
end
|
|
1649
|
+
|
|
1650
|
+
# What keeps the worker up for this request, nil when nothing does; a
|
|
1651
|
+
# stop request stops what the user can stop first (#exit_request).
|
|
1652
|
+
# @return [WorkerIdleExit::Hold, nil]
|
|
1653
|
+
def exit_hold(client_id, kind:, stop:, ask:)
|
|
1654
|
+
if kind == :restart
|
|
1655
|
+
hold = @idle_exit.hold_for_restart
|
|
1656
|
+
return hold && WorkerIdleExit::Hold.new(reason: hold)
|
|
1657
|
+
end
|
|
1658
|
+
|
|
1659
|
+
# /exit names the tasks it waits for (D3), from the one look that found them.
|
|
1660
|
+
# The ask covers the delegate children too, but only for a plain /exit: an
|
|
1661
|
+
# exit that deletes the session, or one a UI won't ask about, ignores them.
|
|
1662
|
+
hold = @idle_exit.request_hold(requester: client_id, hint: TASK_STOP_HINT, ask: ask && kind != :delete)
|
|
1663
|
+
return nil unless hold
|
|
1664
|
+
# A hold the user can't stop here: no ask, today's refusal.
|
|
1665
|
+
return hold unless hold.ask?
|
|
1666
|
+
# The ask's 409: the list, for the terminal to ask with.
|
|
1667
|
+
return hold unless stop
|
|
1668
|
+
|
|
1669
|
+
@stopped_holds = @hold_stop.stop_all
|
|
1670
|
+
# An unstoppable hold may have arrived while stopping (a child stopped
|
|
1671
|
+
# here is no longer counted).
|
|
1672
|
+
@idle_exit.request_hold(requester: client_id, hint: TASK_STOP_HINT)
|
|
1621
1673
|
end
|
|
1622
1674
|
|
|
1623
1675
|
# #leave_idle for an exit a client asked for: check again with the event
|
|
@@ -1636,8 +1688,7 @@ module Samagotchi
|
|
|
1636
1688
|
@idle_exit.hold_for_request(requester: @exit_requested_by, streams: false)
|
|
1637
1689
|
end
|
|
1638
1690
|
if hold
|
|
1639
|
-
|
|
1640
|
-
@exit_restart = false
|
|
1691
|
+
forget_exit
|
|
1641
1692
|
Log.info(:worker, "exit_held", reason: hold)
|
|
1642
1693
|
next false
|
|
1643
1694
|
end
|
|
@@ -1666,8 +1717,7 @@ module Samagotchi
|
|
|
1666
1717
|
end
|
|
1667
1718
|
true
|
|
1668
1719
|
rescue StandardError => e
|
|
1669
|
-
|
|
1670
|
-
@exit_restart = false
|
|
1720
|
+
forget_exit
|
|
1671
1721
|
Log.warn(:worker, "reminders_carry_failed", error: e.class.name, msg: e.message)
|
|
1672
1722
|
false
|
|
1673
1723
|
end
|
|
@@ -39,6 +39,23 @@ module Samagotchi
|
|
|
39
39
|
def ask? = !holds.empty?
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# The 409 reason for an exit request when another client's exit, of
|
|
43
|
+
# another kind, is already taken (Worker#exit_request): the first one
|
|
44
|
+
# decides how the worker leaves, so a restart asked after a plain /exit
|
|
45
|
+
# is refused rather than told "restarting".
|
|
46
|
+
LEAVING = { exit: :leaving_exit, delete: :leaving_delete, restart: :leaving_restart }.freeze
|
|
47
|
+
# What each of those says, for every client's refusal line
|
|
48
|
+
# (TerminalUI::ExitLines, SessionManager::RestartRefused).
|
|
49
|
+
LEAVING_WORDS = {
|
|
50
|
+
leaving_exit: "the worker is already leaving as a plain exit",
|
|
51
|
+
leaving_delete: "the worker is already leaving as an exit that deletes the session",
|
|
52
|
+
leaving_restart: "the worker is already leaving as a restart"
|
|
53
|
+
}.freeze
|
|
54
|
+
|
|
55
|
+
# @param kind [Symbol] the exit already taken (:exit, :delete or :restart)
|
|
56
|
+
# @return [Hold] the refusal for a request of another kind
|
|
57
|
+
def self.leaving(kind) = Hold.new(reason: LEAVING.fetch(kind))
|
|
58
|
+
|
|
42
59
|
# One kind of hold the exit ask offers to stop: +kind+ (:reminders,
|
|
43
60
|
# :background_tasks or :delegates) and its +items+ (#HoldItem). The 409's
|
|
44
61
|
# +holds+ is this in JSON: `{kind:, items: [{id:, label:, detail:}]}`.
|
|
@@ -121,7 +121,7 @@ module Samagotchi
|
|
|
121
121
|
# @param owner [OwnerLock::Owner, nil, :read] the lock's holder as the
|
|
122
122
|
# caller already read it (nil: free); :read reads it here
|
|
123
123
|
# @return [Live, Starting, Leaving, None, Stale, Skew]
|
|
124
|
-
def resolve(session_dir, unlink:, owner: :read
|
|
124
|
+
def resolve(session_dir, unlink:, owner: :read)
|
|
125
125
|
owner = OwnerLock.owner(session_dir) if owner == :read
|
|
126
126
|
stamp = file_stamp(session_dir)
|
|
127
127
|
sidecar = WorkerSidecar.read(session_dir)
|
|
@@ -136,7 +136,7 @@ module Samagotchi
|
|
|
136
136
|
|
|
137
137
|
# @return [BridgeClient, nil] a client for the session's Live worker
|
|
138
138
|
def client(session_id, session_dir:, unlink: true, host: BridgeClient::HOST, read_timeout: BridgeClient::READ_TIMEOUT)
|
|
139
|
-
link = resolve(session_dir, unlink: unlink
|
|
139
|
+
link = resolve(session_dir, unlink: unlink)
|
|
140
140
|
link.live? ? link.client(session_id, host: host, read_timeout: read_timeout) : nil
|
|
141
141
|
end
|
|
142
142
|
|
|
@@ -152,11 +152,11 @@ module Samagotchi
|
|
|
152
152
|
# @return [Live, Starting, Leaving, None, Stale, Skew] the outcome it
|
|
153
153
|
# stopped at, else the last one seen
|
|
154
154
|
def await(_session_id, session_dir:, timeout:, unlink: true, interval: BridgeClient::SPAWN_POLL_INTERVAL,
|
|
155
|
-
until: nil, give_up: nil
|
|
155
|
+
until: nil, give_up: nil)
|
|
156
156
|
wanted = binding.local_variable_get(:until) # a keyword: no plain read
|
|
157
157
|
last = nil
|
|
158
158
|
found = BridgeClient.poll(timeout, interval: interval) do
|
|
159
|
-
last = resolve(session_dir, unlink: unlink
|
|
159
|
+
last = resolve(session_dir, unlink: unlink)
|
|
160
160
|
last if (last.live? && (wanted.nil? || wanted.call(last))) || give_up&.call(last)
|
|
161
161
|
end
|
|
162
162
|
found || last
|
|
@@ -189,7 +189,15 @@ module Samagotchi
|
|
|
189
189
|
def unowned(session_dir, sidecar, owner, stamp, unlink)
|
|
190
190
|
return None.new(owner: owner) unless sidecar
|
|
191
191
|
|
|
192
|
-
|
|
192
|
+
if unlink
|
|
193
|
+
# A worker that took the lock and wrote its sidecar since the lock
|
|
194
|
+
# was read (before the stamp): its fresh file is the one stamped, so
|
|
195
|
+
# read the lock again just before the unlink, and take that worker.
|
|
196
|
+
fresh = OwnerLock.owner(session_dir)
|
|
197
|
+
return resolve(session_dir, unlink: false, owner: fresh) if fresh&.worker?
|
|
198
|
+
|
|
199
|
+
unlink_unchanged(session_dir, stamp)
|
|
200
|
+
end
|
|
193
201
|
Stale.new(sidecar: sidecar, reason: :lock_free, owner: owner)
|
|
194
202
|
end
|
|
195
203
|
private_class_method :unowned
|