samagotchi 0.168.0 → 0.169.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/configuration.md +1 -1
  5. data/docs/guardrails.md +2 -2
  6. data/docs/hooks.md +2 -2
  7. data/docs/internals/block-renderers.md +1 -1
  8. data/docs/internals/bridge.md +16 -4
  9. data/docs/internals/inline-widgets.md +1 -1
  10. data/docs/internals/objectives.md +2 -1
  11. data/docs/internals/remote-sessions.md +1 -1
  12. data/docs/internals/web-frontend.md +62 -22
  13. data/docs/plugins.md +1 -1
  14. data/docs/releasing.md +10 -6
  15. data/docs/testing.md +12 -0
  16. data/lib/samagotchi/bridge.rb +12 -2
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/client_id.rb +3 -0
  19. data/lib/samagotchi/command_intake.rb +7 -7
  20. data/lib/samagotchi/compat.rb +14 -5
  21. data/lib/samagotchi/engine/objectives.rb +10 -1
  22. data/lib/samagotchi/guardrails/approvals.rb +8 -3
  23. data/lib/samagotchi/session_continue.rb +8 -11
  24. data/lib/samagotchi/session_manager/removal.rb +3 -3
  25. data/lib/samagotchi/session_stores.rb +79 -0
  26. data/lib/samagotchi/turn_stages/objectives.rb +6 -8
  27. data/lib/samagotchi/version.rb +1 -1
  28. data/lib/samagotchi/web/public/app.js +3 -1
  29. data/lib/samagotchi/web/public/block_watch.js +1 -1
  30. data/lib/samagotchi/web/public/cards_place.js +5 -1
  31. data/lib/samagotchi/web/public/index.html +58 -39
  32. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  33. data/lib/samagotchi/web/public/question_cards.js +20 -6
  34. data/lib/samagotchi/web/public/stage_asking.js +283 -0
  35. data/lib/samagotchi/web/public/stage_frame.js +7 -11
  36. data/lib/samagotchi/web/public/stage_model.js +37 -11
  37. data/lib/samagotchi/web/public/stage_opened.js +28 -18
  38. data/lib/samagotchi/web/public/stage_slots.js +13 -89
  39. data/lib/samagotchi/web/public/stage_view.js +202 -160
  40. data/lib/samagotchi/web/public/stage_waiting.js +180 -0
  41. data/lib/samagotchi/web/public/stream_handlers.js +1 -1
  42. data/lib/samagotchi/web/public/turn_model.js +1 -1
  43. data/lib/samagotchi/web/session_show.rb +14 -4
  44. data/lib/samagotchi/worker.rb +3 -4
  45. metadata +4 -1
@@ -0,0 +1,180 @@
1
+ // The stage's waiting cards as data (stage_view.js, stage_asking.js): what
2
+ // waits in the stage, keyed by the card's id, never by its node (card.js
3
+ // drawCard may swap a plugin card's DIV for a DETAILS; a question card is
4
+ // drawn anew on a resync). Pure: no DOM but the key and kind read off a card
5
+ // as it arrives.
6
+ //
7
+ // Its feed: +arrive+ from stage_view.js place (a card the stage keeps),
8
+ // +settle+ from question_cards.js's resolve path (onSettled: answered,
9
+ // cancelled, replaced, removed; the continue card resolved or dropped), from
10
+ // cards_place.js upsertCard for a plugin card redrawn without its actions,
11
+ // and from stage_view.js turnEnded for the warn cards leaving with the
12
+ // block; +reset+ from stage_view.js emptySlots. A second feed only settles:
13
+ // +reconcile+ (stage_asking.js: at each refresh, and before the fold, the
14
+ // blocking read and an opened row) drops an entry whose card
15
+ // is gone or waits no more, for a card answered outside question_cards.js
16
+ // (the stage's unit tests answer a card by its classes alone). It never
17
+ // adds or docks: arrive is the one way in.
18
+ //
19
+ // A card's entry: its kind (KINDS), docked (it shows alone in the box above
20
+ // the composer, its turn opened in the history: stage_asking.js docks; its
21
+ // lead-in bubble is stage_opened.js's, by the same key), whether it is an
22
+ // approval (a delegate's relayed one too: raiseForApproval) and its arrival
23
+ // order. The order the stage shows them in: blocking cards first, then the
24
+ // asking ones (and the warnings), each in arrival order, so a live page and
25
+ // a reload agree whatever order the replay brings them in. One docked card
26
+ // shows at a time (+shown+: the one the pager is on, else the first). The
27
+ // fold (the docked card's own, stage_asking.js) is the folded card's key and
28
+ // how many cards waited when it was folded.
29
+ import { ASKS, PENDING, WARN_CARD } from "./stage_cards.js";
30
+
31
+ // The kinds: what blocks the turn (BLOCKING), a running turn's card with
32
+ // actions (check-in: "asks"), a plugin's warning ("warn": stays in sight,
33
+ // waits for nobody).
34
+ export const KINDS = ["question", "approval", "relay", "hook", "continue", "asks", "warn"];
35
+ const BLOCKING = new Set(["question", "approval", "relay", "hook", "continue"]);
36
+ // +kind+ stops the turn until the user answers.
37
+ export const isBlocking = (kind) => BLOCKING.has(kind);
38
+ // An approval card (question_cards.js data-kind), a delegate's relayed one too.
39
+ const APPROVAL_CARD = '.bubble.question[data-kind="approval"]';
40
+ export const isApprovalCard = (el) => !!el?.matches?.(APPROVAL_CARD);
41
+
42
+ // The old continue card has no id: one at a time.
43
+ export const CONTINUE_KEY = "continue";
44
+
45
+ // A question card's key (its data-qid), a plugin card's (its data-card-id).
46
+ export const questionKey = (qid) => (qid == null ? null : `q:${qid}`);
47
+ export const pluginCardKey = (id) => (id == null || id === "" ? null : `c:${id}`);
48
+
49
+ // +el+'s key, null: not a card the stage keeps.
50
+ export function waitingKey(el) {
51
+ if (!el?.matches) return null;
52
+ if (el.matches(".bubble.question")) return questionKey(el.dataset.qid);
53
+ if (el.matches(".bubble.continue")) return CONTINUE_KEY;
54
+ if (el.matches(".plugin-card")) return pluginCardKey(el.dataset.cardId);
55
+ return null;
56
+ }
57
+
58
+ // +el+'s kind (KINDS), null: none (it doesn't wait in the stage).
59
+ export function waitingKind(el) {
60
+ if (!el?.matches) return null;
61
+ if (el.matches(PENDING)) {
62
+ if (el.matches(".bubble.continue, .step-limit") || el.dataset.kind === "continue") return "continue";
63
+ if (el.dataset.relay) return "relay";
64
+ if (el.dataset.kind === "approval") return "approval";
65
+ if (el.dataset.kind === "hook") return "hook";
66
+ return "question";
67
+ }
68
+ if (el.querySelector?.(ASKS)) return "asks";
69
+ if (el.matches(WARN_CARD)) return "warn";
70
+ return null;
71
+ }
72
+
73
+ // +root+'s child card with +key+ (the stage's extras or tail), null: none.
74
+ export function cardIn(root, key) {
75
+ if (!root || !key) return null;
76
+ return [...root.children].find((el) => waitingKey(el) === key) || null;
77
+ }
78
+
79
+ // +kind+'s rank in the stage's order: blocking cards before the rest.
80
+ const rank = (kind) => (BLOCKING.has(kind) ? 0 : 1);
81
+
82
+ export function createStageWaiting() {
83
+ const cards = new Map();
84
+ let seq = 0;
85
+ let folded = null;
86
+ let foldedWith = 0;
87
+ // The docked card the pager is on (null: the first).
88
+ let paged = null;
89
+
90
+ const entries = () => [...cards.entries()].sort((a, b) => rank(a[1].kind) - rank(b[1].kind) || a[1].order - b[1].order);
91
+ const dockedKeys = () => entries().filter(([, e]) => e.docked).map(([key]) => key);
92
+
93
+ // Cards that wait for the user: the blocking ones, with +asks+ the asking
94
+ // ones too.
95
+ function count({ asks = false } = {}) {
96
+ let n = 0;
97
+ for (const { kind } of cards.values()) if (BLOCKING.has(kind) || (asks && kind === "asks")) n += 1;
98
+ return n;
99
+ }
100
+
101
+ return {
102
+ // +key+ came to the stage (again: its kind and flags change, its place in
103
+ // the order stays).
104
+ arrive(key, { kind, docked = false, approval = false } = {}) {
105
+ if (!key || !KINDS.includes(kind)) return;
106
+ const was = cards.get(key);
107
+ // A new card goes in front of what the pager was on.
108
+ if (!was) paged = null;
109
+ cards.set(key, {
110
+ kind, docked: !!docked, approval: !!approval, order: was ? was.order : seq++,
111
+ });
112
+ },
113
+ // +key+ stopped waiting (+only+: when it is that kind).
114
+ settle(key, { only = null } = {}) {
115
+ const was = cards.get(key);
116
+ if (!was || (only && was.kind !== only)) return false;
117
+ cards.delete(key);
118
+ return true;
119
+ },
120
+ // The stage let its turn go: nothing waits, nothing is folded.
121
+ reset() {
122
+ cards.clear();
123
+ seq = 0;
124
+ folded = null;
125
+ foldedWith = 0;
126
+ paged = null;
127
+ },
128
+ // The second feed: every entry +waits+(key, kind) says no to settles.
129
+ reconcile(waits) {
130
+ for (const [key, { kind }] of [...cards.entries()]) if (!waits(key, kind)) cards.delete(key);
131
+ },
132
+ has: (key) => cards.has(key),
133
+ get: (key) => (cards.has(key) ? { key, ...cards.get(key) } : null),
134
+ // The docked cards' keys, in the stage's order.
135
+ docked: dockedKeys,
136
+ // The docked card the stage shows: the one the pager is on while it is
137
+ // docked, else the first (blocking before asking); null: none.
138
+ shown() {
139
+ const keys = dockedKeys();
140
+ return keys.includes(paged) ? paged : keys[0] ?? null;
141
+ },
142
+ // The pager: the docked card +by+ places from the shown one (clamped).
143
+ // The shown key, or null.
144
+ page(by) {
145
+ const keys = dockedKeys();
146
+ if (!keys.length) return null;
147
+ const at = Math.max(0, keys.indexOf(keys.includes(paged) ? paged : keys[0]));
148
+ paged = keys[Math.min(keys.length - 1, Math.max(0, at + by))];
149
+ return paged;
150
+ },
151
+ // The card in front: the first blocking one, else the first asking one;
152
+ // null: none.
153
+ front() {
154
+ const list = entries();
155
+ const hit = list.find(([, e]) => BLOCKING.has(e.kind)) || list.find(([, e]) => e.kind === "asks");
156
+ return hit ? hit[0] : null;
157
+ },
158
+ count,
159
+ // The blocking cards' keys, in order.
160
+ blocking: () => entries().filter(([, e]) => BLOCKING.has(e.kind)).map(([key]) => key),
161
+ // Every entry in the stage's order (the tests' and the harness's read).
162
+ list: () => entries().map(([key, e]) => ({ key, ...e })),
163
+ // The asking form's fold: +key+ folded by hand (null: unfolded), with
164
+ // the blocking cards that wait now.
165
+ fold(key) {
166
+ folded = key ?? null;
167
+ foldedWith = count();
168
+ },
169
+ // The fold as it stands with +asking+ (the asking form's card key) now:
170
+ // a card answered meanwhile lowers the mark, one more waiting than at
171
+ // the fold drops it, and so does another form. The folded key, or null.
172
+ trackFold(asking) {
173
+ const now = count();
174
+ foldedWith = Math.min(foldedWith, now);
175
+ if (folded && (folded !== asking || now > foldedWith)) folded = null;
176
+ return folded;
177
+ },
178
+ folded: () => folded,
179
+ };
180
+ }
@@ -417,7 +417,7 @@ export function createStreamHandlers(deps) {
417
417
  const pq = data.pending_question;
418
418
  if (!pq || !pq.id) return;
419
419
  // A plugin's refs on the reply that asks: on its step before the card
420
- // draws the asking slot.
420
+ // draws its lead-in bubble.
421
421
  if (pq.refs) turnDom.questionRefs(pq.refs);
422
422
  questionCards.renderQuestion(pq);
423
423
  },
@@ -176,7 +176,7 @@ export function askingText(turn) {
176
176
 
177
177
  // A plugin's refs on the reply that asks (a question's refs, from its
178
178
  // pending question): kept on the asking step's gen, the last one, while
179
- // the turn goes on, for the stage's asking slot (askingRefs) and the
179
+ // the turn goes on, for the lead-in bubble (askingRefs) and the
180
180
  // step's closed text (turn_block.js). A non-array or an empty list keeps
181
181
  // what the gen has. The gen, or null.
182
182
  export function questionRefs(turn, refs) {
@@ -110,16 +110,18 @@ module Samagotchi
110
110
  # instead of taking the seq as its own. Only the snapshot has it.
111
111
  last_event_id: turn_snapshot && turn_snapshot["event_id"],
112
112
  timing: timing,
113
- **objectives_field(id, raw_messages)
113
+ **objectives_field(id, raw_messages, turn_snapshot)
114
114
  })
115
115
  end
116
116
 
117
117
  # The session's list (the page's strip), and no key with no list: a
118
118
  # session without one reads as it always did. A snapshot's entries have
119
119
  # string keys (paired_runs reads symbols).
120
- def objectives_field(id, messages)
121
- symbols = Array(messages).map { |message| message.is_a?(Hash) ? message.transform_keys(&:to_sym) : message }
122
- view = Samagotchi::Objectives.view(id, symbols, state_dir: default_state_dir)
120
+ def objectives_field(id, messages, live)
121
+ view = live_key_or_saved(live, "objectives") do
122
+ symbols = Array(messages).map { |message| message.is_a?(Hash) ? message.transform_keys(&:to_sym) : message }
123
+ Samagotchi::Objectives.view(id, symbols, state_dir: default_state_dir)
124
+ end
123
125
  view ? { objectives: view } : {}
124
126
  end
125
127
 
@@ -180,6 +182,14 @@ module Samagotchi
180
182
  live ? live[key] : yield
181
183
  end
182
184
 
185
+ # Use the worker's value when its snapshot has the key (nil included);
186
+ # a key missing from an older worker falls back to the saved session.
187
+ def live_key_or_saved(live, key)
188
+ return live[key] if live&.key?(key)
189
+
190
+ yield
191
+ end
192
+
183
193
  # The file's question only while a worker owns the session: one a
184
194
  # dead worker saved can't be answered (its turn is gone), nor a
185
195
  # one-shot's or an older chi's REPL's (they share nothing with the web).
@@ -2042,13 +2042,12 @@ module Samagotchi
2042
2042
  Log.info(:worker, "idle_exit", idle_s: @idle_exit.idle_seconds.round)
2043
2043
  end
2044
2044
 
2045
- # A human's input (ClientId.human?) un-archives the session;
2046
- # a delegate's, a plugin's or a reminder's doesn't, and a participant's
2047
- # doesn't either: it is no owner's input.
2045
+ # The owner's input (ClientId.owner?) un-archives the session; a
2046
+ # delegate's, a plugin's, a reminder's or a participant's doesn't.
2048
2047
  # It also resets the wakes (budget and pause); input typed in the web or
2049
2048
  # a TUI the peer wakes too (chi send doesn't: a peer can run it).
2050
2049
  def user_input(client_id)
2051
- return unless ClientId.human?(client_id) && !ClientId.review?(client_id)
2050
+ return unless ClientId.owner?(client_id)
2052
2051
 
2053
2052
  ArchiveStore.user_input(@session_id, state_dir: @state_dir)
2054
2053
  @wakes.human_input!(interactive: ClientId.interactive?(client_id))
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.168.0
4
+ version: 0.169.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -703,6 +703,7 @@ files:
703
703
  - lib/samagotchi/session_row.rb
704
704
  - lib/samagotchi/session_setting.rb
705
705
  - lib/samagotchi/session_setup.rb
706
+ - lib/samagotchi/session_stores.rb
706
707
  - lib/samagotchi/sessions_command.rb
707
708
  - lib/samagotchi/setting_explained.rb
708
709
  - lib/samagotchi/share_command.rb
@@ -1044,6 +1045,7 @@ files:
1044
1045
  - lib/samagotchi/web/public/settings_panel.js
1045
1046
  - lib/samagotchi/web/public/shell_command.js
1046
1047
  - lib/samagotchi/web/public/shown_texts.js
1048
+ - lib/samagotchi/web/public/stage_asking.js
1047
1049
  - lib/samagotchi/web/public/stage_cards.js
1048
1050
  - lib/samagotchi/web/public/stage_frame.js
1049
1051
  - lib/samagotchi/web/public/stage_model.js
@@ -1051,6 +1053,7 @@ files:
1051
1053
  - lib/samagotchi/web/public/stage_slots.js
1052
1054
  - lib/samagotchi/web/public/stage_stall.js
1053
1055
  - lib/samagotchi/web/public/stage_view.js
1056
+ - lib/samagotchi/web/public/stage_waiting.js
1054
1057
  - lib/samagotchi/web/public/stage_watch.js
1055
1058
  - lib/samagotchi/web/public/stale_params.js
1056
1059
  - lib/samagotchi/web/public/start_page.js