samagotchi 0.156.0 → 0.158.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 +70 -1
- data/README.md +1 -1
- data/bin/chi +4 -1
- data/docs/architecture.md +18 -12
- data/docs/bundle-authoring.md +5 -5
- data/docs/bundles.md +4 -1
- data/docs/cli.md +29 -24
- data/docs/configuration.md +43 -9
- data/docs/context.md +5 -3
- data/docs/guardrails.md +12 -7
- data/docs/hooks.md +53 -83
- data/docs/internals/bridge.md +84 -7
- data/docs/internals/bundles.md +69 -46
- data/docs/internals/context-telemetry.md +4 -3
- data/docs/internals/engine.md +23 -7
- data/docs/internals/participants.md +3 -2
- data/docs/internals/remote-sessions.md +5 -2
- data/docs/internals/web-frontend.md +3 -2
- data/docs/internals/working-set.md +1 -1
- data/docs/memory.md +51 -10
- data/docs/plugins.md +48 -11
- data/docs/releasing.md +6 -5
- data/docs/sessions.md +13 -13
- data/docs/sub-agent.md +17 -2
- data/docs/testing.md +56 -5
- data/lib/samagotchi/answer_command.rb +2 -1
- data/lib/samagotchi/archive_store.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +3 -2
- data/lib/samagotchi/bridge/card_places.rb +5 -7
- data/lib/samagotchi/bridge/card_store.rb +3 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +113 -10
- data/lib/samagotchi/bridge/turn_drawings.rb +271 -0
- data/lib/samagotchi/bridge.rb +31 -6
- data/lib/samagotchi/broadcast/recipients.rb +8 -8
- data/lib/samagotchi/broadcast/triage_model.rb +1 -1
- data/lib/samagotchi/bundle_command/check.rb +41 -10
- data/lib/samagotchi/bundle_command/installed_check.rb +13 -2
- data/lib/samagotchi/bundle_command/try/run.rb +4 -1
- data/lib/samagotchi/bundle_command/try/sandbox.rb +1 -1
- data/lib/samagotchi/bundle_command/try/trace.rb +1 -1
- data/lib/samagotchi/bundle_command.rb +20 -45
- data/lib/samagotchi/bundles/known-names/manifest.yml +4 -7
- data/lib/samagotchi/bundles/known-names/{hooks/known_names.rb → plugin.rb} +17 -0
- data/lib/samagotchi/bundles/source-links/manifest.yml +4 -7
- data/lib/samagotchi/bundles/source-links/{hooks/source_links.rb → plugin.rb} +23 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +4 -4
- data/lib/samagotchi/bundles/system/memory_guide.md +7 -7
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/child_move.rb +6 -6
- data/lib/samagotchi/child_reports.rb +2 -2
- data/lib/samagotchi/children_status.rb +15 -21
- data/lib/samagotchi/cli/parent_wait.rb +8 -22
- data/lib/samagotchi/config.rb +3 -0
- data/lib/samagotchi/context_command.rb +7 -5
- data/lib/samagotchi/context_sources.rb +9 -6
- data/lib/samagotchi/context_window.rb +9 -12
- data/lib/samagotchi/effective_model.rb +57 -22
- data/lib/samagotchi/engine/plugins.rb +1 -1
- data/lib/samagotchi/engine/recap.rb +8 -4
- data/lib/samagotchi/engine.rb +30 -9
- data/lib/samagotchi/events.rb +3 -2
- data/lib/samagotchi/extension_load.rb +26 -44
- data/lib/samagotchi/guardrails/load_failures.rb +3 -2
- data/lib/samagotchi/guardrails/path_resolve.rb +11 -0
- data/lib/samagotchi/guardrails/review_paths.rb +1 -5
- data/lib/samagotchi/guardrails_command.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +2 -2
- data/lib/samagotchi/hooks.rb +2 -3
- data/lib/samagotchi/host_address.rb +52 -0
- data/lib/samagotchi/host_registry.rb +57 -45
- data/lib/samagotchi/kernel_loop.rb +7 -3
- data/lib/samagotchi/llm/chat_loop.rb +6 -3
- data/lib/samagotchi/llm_context_strategy.rb +27 -63
- data/lib/samagotchi/memory_bundle/asset_installer.rb +92 -177
- data/lib/samagotchi/memory_bundle/builder.rb +12 -35
- data/lib/samagotchi/memory_bundle/bundle_hook.rb +6 -31
- data/lib/samagotchi/memory_bundle/file_actions.rb +193 -0
- data/lib/samagotchi/memory_bundle/file_digest.rb +13 -0
- data/lib/samagotchi/memory_bundle/file_state.rb +152 -0
- data/lib/samagotchi/memory_bundle/installed_bundle.rb +1 -1
- data/lib/samagotchi/memory_bundle/installed_copy.rb +13 -19
- data/lib/samagotchi/memory_bundle/installed_manifest.rb +10 -17
- data/lib/samagotchi/memory_bundle/installer.rb +212 -178
- data/lib/samagotchi/memory_bundle/manifest.rb +51 -58
- data/lib/samagotchi/memory_bundle/merger.rb +69 -65
- data/lib/samagotchi/memory_bundle/plugin_ref.rb +6 -5
- data/lib/samagotchi/memory_bundle/profile.rb +5 -5
- data/lib/samagotchi/memory_bundle/provenance.rb +26 -52
- data/lib/samagotchi/memory_bundle/remerge.rb +31 -14
- data/lib/samagotchi/memory_bundle/shas.rb +5 -8
- data/lib/samagotchi/memory_bundle/shipped_update.rb +51 -11
- data/lib/samagotchi/memory_bundle/status.rb +26 -30
- data/lib/samagotchi/memory_bundle/system_bundle.rb +39 -10
- data/lib/samagotchi/memory_bundle/trash.rb +28 -18
- data/lib/samagotchi/memory_bundle/uninstaller.rb +16 -11
- data/lib/samagotchi/memory_orphans.rb +4 -4
- data/lib/samagotchi/memory_paths.rb +102 -13
- data/lib/samagotchi/memory_usage.rb +1 -1
- data/lib/samagotchi/model_lookup.rb +75 -0
- data/lib/samagotchi/model_profile.rb +1 -0
- data/lib/samagotchi/models_command.rb +1 -1
- data/lib/samagotchi/note_command.rb +1 -1
- data/lib/samagotchi/participant_mode.rb +2 -1
- data/lib/samagotchi/plugin/api.rb +42 -10
- data/lib/samagotchi/plugin/loader.rb +75 -15
- data/lib/samagotchi/plugin/sessions.rb +1 -1
- data/lib/samagotchi/project_command.rb +167 -0
- data/lib/samagotchi/project_init.rb +69 -0
- data/lib/samagotchi/project_scope.rb +6 -4
- data/lib/samagotchi/prompt_files.rb +10 -7
- data/lib/samagotchi/reasoning_history.rb +14 -20
- data/lib/samagotchi/recap_place.rb +26 -0
- data/lib/samagotchi/reply_wait/liveness.rb +38 -0
- data/lib/samagotchi/reply_wait/poll.rb +44 -15
- data/lib/samagotchi/reply_wait/target.rb +113 -0
- data/lib/samagotchi/reply_wait.rb +22 -2
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/sampling_settings.rb +9 -16
- data/lib/samagotchi/self_report.rb +34 -35
- data/lib/samagotchi/send_command.rb +17 -23
- data/lib/samagotchi/session.rb +349 -441
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +5 -6
- data/lib/samagotchi/session_access/remote.rb +5 -3
- data/lib/samagotchi/session_field.rb +134 -0
- data/lib/samagotchi/session_inbox.rb +49 -1
- data/lib/samagotchi/session_manager/emptiness.rb +2 -1
- data/lib/samagotchi/session_manager/listing.rb +29 -100
- data/lib/samagotchi/session_manager.rb +2 -1
- data/lib/samagotchi/session_metrics.rb +7 -4
- data/lib/samagotchi/session_paths.rb +57 -0
- data/lib/samagotchi/session_retention.rb +2 -2
- data/lib/samagotchi/session_row.rb +200 -0
- data/lib/samagotchi/session_setup.rb +15 -12
- data/lib/samagotchi/sessions_command.rb +11 -129
- data/lib/samagotchi/steer.rb +10 -0
- data/lib/samagotchi/steer_cut_after.rb +7 -14
- data/lib/samagotchi/system_prompt.rb +20 -11
- data/lib/samagotchi/terminal_ui/attached_loop.rb +6 -26
- data/lib/samagotchi/terminal_ui/join_replay.rb +91 -8
- data/lib/samagotchi/terminal_ui/local_link.rb +2 -2
- data/lib/samagotchi/terminal_ui.rb +3 -1
- data/lib/samagotchi/testing/bundle.rb +5 -12
- data/lib/samagotchi/testing/install.rb +5 -4
- data/lib/samagotchi/testing/recorder.rb +1 -1
- data/lib/samagotchi/testing.rb +1 -1
- data/lib/samagotchi/thinking.rb +16 -21
- data/lib/samagotchi/tool_declarations.rb +1 -1
- data/lib/samagotchi/tool_response.rb +5 -3
- data/lib/samagotchi/tool_runner.rb +18 -1
- data/lib/samagotchi/toolkit_binding.rb +1 -4
- data/lib/samagotchi/toolkit_command.rb +1 -4
- data/lib/samagotchi/tools/delegate.rb +60 -51
- data/lib/samagotchi/tools/delegate_relay.rb +5 -5
- data/lib/samagotchi/tools/delegate_result.rb +1 -1
- data/lib/samagotchi/tools/delegate_wait.rb +41 -17
- data/lib/samagotchi/tools/list_sessions.rb +7 -7
- data/lib/samagotchi/update_command.rb +11 -7
- data/lib/samagotchi/verify_fresh.rb +7 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +11 -14
- data/lib/samagotchi/web/app.rb +66 -21
- data/lib/samagotchi/web/message_display.rb +57 -7
- data/lib/samagotchi/web/participant_access.rb +1 -1
- data/lib/samagotchi/web/project_list.rb +3 -2
- data/lib/samagotchi/web/public/activity.js +16 -7
- data/lib/samagotchi/web/public/app.js +29 -8
- data/lib/samagotchi/web/public/bubbles.js +8 -3
- data/lib/samagotchi/web/public/cards_place.js +15 -0
- data/lib/samagotchi/web/public/commands.js +5 -1
- data/lib/samagotchi/web/public/data.js +4 -2
- data/lib/samagotchi/web/public/history_shape.js +220 -0
- data/lib/samagotchi/web/public/index.html +6 -1
- data/lib/samagotchi/web/public/project_picker.js +86 -4
- data/lib/samagotchi/web/public/recap_stamp.js +17 -0
- data/lib/samagotchi/web/public/reload_check.js +71 -0
- data/lib/samagotchi/web/public/stage_model.js +7 -5
- data/lib/samagotchi/web/public/stage_view.js +7 -3
- data/lib/samagotchi/web/public/stream_handlers.js +8 -2
- data/lib/samagotchi/web/public/timing.js +43 -2
- data/lib/samagotchi/web/public/turn_block.js +215 -30
- data/lib/samagotchi/web/public/turn_drawing.js +43 -0
- data/lib/samagotchi/web/public/turn_events.js +10 -1
- data/lib/samagotchi/web/public/turn_pieces.js +29 -0
- data/lib/samagotchi/web/reload_check_route.rb +95 -0
- data/lib/samagotchi/web/server.rb +4 -2
- data/lib/samagotchi/web/session_access_routes.rb +3 -3
- data/lib/samagotchi/web/session_control.rb +1 -1
- data/lib/samagotchi/web/session_hub.rb +9 -17
- data/lib/samagotchi/web/session_show.rb +64 -2
- data/lib/samagotchi/web/session_summary.rb +36 -24
- data/lib/samagotchi/worker.rb +28 -5
- data/lib/samagotchi/worker_link.rb +3 -0
- metadata +22 -5
- data/lib/samagotchi/hooks/bundle_loader.rb +0 -191
- data/lib/samagotchi/send_command/wait_target.rb +0 -85
|
@@ -28,19 +28,24 @@ import { html, nothing, render } from "./vendor/lit-html.js";
|
|
|
28
28
|
import { answerRefsOf, setAnswerRefs } from "./answer_refs.js";
|
|
29
29
|
import { bindRowRefs } from "./refs.js";
|
|
30
30
|
import { openAfterEnd } from "./block_open.js";
|
|
31
|
-
import { leavesBlock } from "./card.js";
|
|
32
|
-
import {
|
|
31
|
+
import { drawCard, isNoticeCard, leavesBlock, shownElsewhere } from "./card.js";
|
|
32
|
+
import { routeChunk } from "./chunk_router.js";
|
|
33
|
+
import { drawCommandBubble } from "./commands.js";
|
|
34
|
+
import { endBubbleTemplate } from "./bubbles.js";
|
|
35
|
+
import { drawnKind } from "./turn_drawing.js";
|
|
36
|
+
import { failedTurnText, fullTime, normalize, sourceLabel } from "./format.js";
|
|
33
37
|
import { thumbsTemplate } from "./images.js";
|
|
34
38
|
import { shouldFollowScroll } from "./scroll.js";
|
|
35
|
-
import { hookNoticeLabel } from "./turn_events.js";
|
|
39
|
+
import { emptyRetryLine, hookNoticeLabel, llmContextNotice, reminderText, steerCutLine } from "./turn_events.js";
|
|
36
40
|
import {
|
|
37
41
|
answerBubbleTemplate, cancelLineTemplate, commandBubbleTemplate, elementOf, emptyAnswerTemplate, noteBubbleTemplate, shellCommandMessage,
|
|
38
42
|
steerRowTemplate, toolRowInnerTemplate, toolRowTemplate, userBubbleTemplate,
|
|
39
43
|
} from "./turn_html.js";
|
|
40
|
-
import { formatDuration, turnGroups, turnTimingText } from "./timing.js";
|
|
44
|
+
import { cancelLineText, formatDuration, turnGroups, turnTimingText } from "./timing.js";
|
|
41
45
|
import { createSentenceFeed } from "./sentences.js";
|
|
42
46
|
import { createTicker } from "./thinking_ticker.js";
|
|
43
47
|
import * as model from "./turn_model.js";
|
|
48
|
+
import { answerGen, turnPieces } from "./turn_pieces.js";
|
|
44
49
|
import { linkedLine } from "./notice_links.js";
|
|
45
50
|
import { taskCommand, taskStopButton } from "./task_stop.js";
|
|
46
51
|
import { footprintTemplate, groupItems, stampHistory } from "./working_set_view.js";
|
|
@@ -212,6 +217,7 @@ export function createTurnBlock({
|
|
|
212
217
|
onChange = () => {},
|
|
213
218
|
onThinkingLine = () => {},
|
|
214
219
|
taskStop = null,
|
|
220
|
+
thumbs = null,
|
|
215
221
|
}) {
|
|
216
222
|
let turn = null;
|
|
217
223
|
let blockEl = null;
|
|
@@ -482,7 +488,7 @@ export function createTurnBlock({
|
|
|
482
488
|
|
|
483
489
|
// A live tool row is its own element, the reloaded row's template
|
|
484
490
|
// (turn_html.js toolRowInnerTemplate) rendered into it on each event.
|
|
485
|
-
const rowThumbs = (images) => thumbsTemplate(sessionId(), images);
|
|
491
|
+
const rowThumbs = thumbs || ((images) => thumbsTemplate(sessionId(), images));
|
|
486
492
|
// A row element → its stop-task button (syncTaskStop).
|
|
487
493
|
const rowStops = new WeakMap();
|
|
488
494
|
|
|
@@ -643,14 +649,13 @@ export function createTurnBlock({
|
|
|
643
649
|
function turnEnded({ kind, endNote = null, turnId = null }) {
|
|
644
650
|
// Once per turn: a second end (a re-dispatched event) would promote again.
|
|
645
651
|
if (!turn || turn.ended) return [];
|
|
646
|
-
const last = model.currentGen(turn);
|
|
647
652
|
// A completed turn's answer is its last generation's text; a one-step
|
|
648
653
|
// turn without tools leaves the block in any case (canceled, it is the
|
|
649
|
-
// partial text bubble).
|
|
650
|
-
const
|
|
654
|
+
// partial text bubble): turn_pieces.js answerGen.
|
|
655
|
+
const last = answerGen(turn, kind);
|
|
651
656
|
if (pendingSteers.length) flushSteers();
|
|
652
657
|
apply(model.turnEnded(turn));
|
|
653
|
-
if (
|
|
658
|
+
if (last) promote(last, { endNote, turnId: kind === "completed" ? turnId : null });
|
|
654
659
|
else if (endNote) placeAnswer([endNote], { plain: false, blockEl });
|
|
655
660
|
const kept = [];
|
|
656
661
|
if (blockEl) {
|
|
@@ -819,6 +824,140 @@ function failedLineTemplate(record) {
|
|
|
819
824
|
return html`<div class="bubble cancel">${failedTurnText(failure)}</div>`;
|
|
820
825
|
}
|
|
821
826
|
|
|
827
|
+
// ---- A drawn turn ----
|
|
828
|
+
|
|
829
|
+
// A drawn turn's card: the snapshot's card of its id when the page has it
|
|
830
|
+
// (+cards+: CardStore's, its latest version, an update after the turn too,
|
|
831
|
+
// as live shows it), else the drawing's with its buttons off. Where it
|
|
832
|
+
// goes is the drawing's (drawnTurnNodes).
|
|
833
|
+
function drawnCardEl(event, cards) {
|
|
834
|
+
const stored = (cards || []).find((c) => c?.type === "card" && String(c.id) === String(event.id));
|
|
835
|
+
const card = stored ? { ...stored, in_turn: true } : event;
|
|
836
|
+
const el = drawCard(document.createElement(isNoticeCard(card) ? "details" : "div"), card);
|
|
837
|
+
// The drawing's own copy (no CardStore card: capped out, or its file
|
|
838
|
+
// gone) can't tell whether its buttons still mean anything: off. A
|
|
839
|
+
// CardStore card's buttons are as CardStore lists them (it drops the
|
|
840
|
+
// dead ones itself: an earlier worker's, a turn that left nothing), as
|
|
841
|
+
// live leaves them.
|
|
842
|
+
if (!stored) el.querySelectorAll(".card-action").forEach((button) => { button.disabled = true; });
|
|
843
|
+
return el;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// A drawn turn's pieces (+group+: turnGroups' drawn group, its drawing
|
|
847
|
+
// {events, end}), drawn by the live turn block itself: its events go
|
|
848
|
+
// through the block's own handlers as the stream handlers send them live
|
|
849
|
+
// (stream_handlers.js, the stage's view), so the steps, their rows,
|
|
850
|
+
// notices, steers and in-turn cards are where live put them, then the
|
|
851
|
+
// turn ends as live ended it. What live put beside the block (the stage's
|
|
852
|
+
// extras: a reminder's bubble, a merged line's, a card that asks or
|
|
853
|
+
// warns) comes back in arrival order. A merged line and the answer are
|
|
854
|
+
// drawn from their saved items (their markdown, refs, keys), the steps'
|
|
855
|
+
// texts take their items' markdown and refs.
|
|
856
|
+
// @return {block, extras: [{el} | {merged: index}], answer: [nodes], end, kept: [nodes]}
|
|
857
|
+
function drawnTurnNodes(group, items, { thumbs, capabilities = {}, cards = null }) {
|
|
858
|
+
const { events = [], end = {} } = group.drawing || {};
|
|
859
|
+
const kind = drawnKind(end);
|
|
860
|
+
let blockEl = null;
|
|
861
|
+
const answer = [];
|
|
862
|
+
const extras = [];
|
|
863
|
+
const view = createTurnBlock({
|
|
864
|
+
historyEl: document.createElement("div"), isNearBottom: () => false, sawText: () => {}, sessionId: () => null,
|
|
865
|
+
thumbs: (images) => thumbs(images), follow: () => {},
|
|
866
|
+
blockHost: (el) => { blockEl = el; },
|
|
867
|
+
placeAnswer: (pieces, { plain }) => {
|
|
868
|
+
if (plain) blockEl = null;
|
|
869
|
+
answer.push(...pieces);
|
|
870
|
+
},
|
|
871
|
+
});
|
|
872
|
+
const merged = [...(group.merged || [])];
|
|
873
|
+
// The ✂ marks a strategy saved on the turn's outputs since (its items'
|
|
874
|
+
// marks, by output id): an overlay on the rows, as a reload's.
|
|
875
|
+
const marks = Object.assign({}, ...items.filter((it) => it?.drawn === group.drawn && it.marks).map((it) => it.marks));
|
|
876
|
+
for (const event of events) {
|
|
877
|
+
switch (event?.type) {
|
|
878
|
+
case "turn_started": view.turnStarted(event); break;
|
|
879
|
+
case "generation_started": view.generationStarted(event, null); break;
|
|
880
|
+
case "generation_chunk": view.chunk({ ...routeChunk(event), iteration: event.iteration ?? null }); break;
|
|
881
|
+
case "generation_completed": view.generationCompleted(); break;
|
|
882
|
+
case "tool_call_started": view.toolStarted(event); break;
|
|
883
|
+
case "tool_call_completed": view.toolCompleted(marks[event.tool_id] ? { ...event, edit: marks[event.tool_id] } : event); break;
|
|
884
|
+
case "hook_notice": if (!shownElsewhere(event, capabilities)) view.notice(event); break;
|
|
885
|
+
case "empty_answer_retry": view.notice({ line: emptyRetryLine(event) }); break;
|
|
886
|
+
case "steer_cut": view.notice({ line: steerCutLine(event), cls: "steer-cut" }); break;
|
|
887
|
+
case "llm_context_edited": view.notice(llmContextNotice(event)); break;
|
|
888
|
+
case "pending_input_merged": (event.steers || []).forEach((steer) => view.steer(steer)); break;
|
|
889
|
+
case "merged_input": extras.push({ merged: merged.shift() ?? null }); break;
|
|
890
|
+
case "reminder_injected": {
|
|
891
|
+
const el = document.createElement("div");
|
|
892
|
+
drawCommandBubble(el, { label: null, line: reminderText(event), text: "" });
|
|
893
|
+
const when = fullTime(event.at);
|
|
894
|
+
if (when) el.title = when;
|
|
895
|
+
extras.push({ el });
|
|
896
|
+
break;
|
|
897
|
+
}
|
|
898
|
+
case "card": {
|
|
899
|
+
// Live: a card that asks (its actions run commands) or warns stays
|
|
900
|
+
// in sight beside the block (cards_place.js upsertCard, the
|
|
901
|
+
// stage's card); any other is a row of its step.
|
|
902
|
+
const el = drawnCardEl(event, cards);
|
|
903
|
+
const asks = (event.actions || []).some((a) => a && a.command);
|
|
904
|
+
const warn = el.classList.contains("warn");
|
|
905
|
+
if (asks || warn || !view.card(el)) extras.push({ el, warn });
|
|
906
|
+
break;
|
|
907
|
+
}
|
|
908
|
+
default: break;
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
const empty = kind === "completed" && (group.emptyAnswer != null || end.turn_summary?.empty_answer);
|
|
912
|
+
const endNote = empty ? elementOf(emptyAnswerTemplate(group.emptyAnswer != null ? items[group.emptyAnswer] : end.turn_summary.empty_answer)) : null;
|
|
913
|
+
const kept = view.turnEnded({ kind, endNote, turnId: end.turn_id || group.drawn }) || [];
|
|
914
|
+
if (endNote && !answer.includes(endNote)) answer.push(endNote);
|
|
915
|
+
// The stage's turn end: the warn cards beside the block leave with the
|
|
916
|
+
// block's, after the end line.
|
|
917
|
+
kept.push(...extras.filter((x) => x.warn).map((x) => x.el));
|
|
918
|
+
const steps = blockEl ? [...blockEl.querySelectorAll(":scope > .gen > .gen-text")] : [];
|
|
919
|
+
pairStepTexts(steps, group, items);
|
|
920
|
+
return {
|
|
921
|
+
block: blockEl,
|
|
922
|
+
extras: extras.filter((x) => !x.warn),
|
|
923
|
+
answer: answer.map((el) => (el.matches?.(".bubble.output") ? savedAnswer(el, group, items, kind) : el)),
|
|
924
|
+
end: kind === "canceled" ? endBubbleTemplate(cancelLineText(end.cancellation_reason || "", end.cancelled_by), { stopped: true })
|
|
925
|
+
: kind === "failed" ? endBubbleTemplate(failedTurnText(end)) : null,
|
|
926
|
+
kept,
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// The live answer bubble as the reload draws it from its saved message
|
|
931
|
+
// (its markdown, refs, display: answerBubbleTemplate), when one of the
|
|
932
|
+
// turn's texts says what it says; else as live drew it.
|
|
933
|
+
function savedAnswer(el, group, items, kind) {
|
|
934
|
+
const text = el.textContent.trim();
|
|
935
|
+
const at = [group.answer, ...[...(group.texts || [])].reverse()].find((i) => i != null && String(items[i]?.content ?? "").trim() === text);
|
|
936
|
+
if (at == null) return el;
|
|
937
|
+
return nodesOf(answerBubbleTemplate(items[at], { at, turnId: kind === "completed" ? group.turnId : null })).firstElementChild;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// The steps' texts paired with the turn's saved texts in order: each its
|
|
941
|
+
// message's index (data-answer-at: its refs) and its rendered markdown,
|
|
942
|
+
// as a reloaded step's (textTemplate).
|
|
943
|
+
function pairStepTexts(els, group, items) {
|
|
944
|
+
const texts = (group.texts || []).filter((i) => i !== group.answer);
|
|
945
|
+
let k = 0;
|
|
946
|
+
for (const el of els) {
|
|
947
|
+
const text = el.textContent.trim();
|
|
948
|
+
const found = texts.slice(k).findIndex((i) => String(items[i]?.content ?? "").trim() === text);
|
|
949
|
+
if (found < 0) continue;
|
|
950
|
+
const at = texts[k + found];
|
|
951
|
+
k += found + 1;
|
|
952
|
+
const rendered = items[at]?.html;
|
|
953
|
+
if (typeof rendered === "string" && rendered) {
|
|
954
|
+
el.replaceWith(nodesOf(textTemplate(String(items[at].content), { live: false, at, rendered })).firstElementChild);
|
|
955
|
+
} else {
|
|
956
|
+
el.dataset.answerAt = String(at);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
822
961
|
// A reloaded history as turn blocks (renderHistory's object path), as
|
|
823
962
|
// nodes: per turn the prompt, a collapsed block of its steps when it had
|
|
824
963
|
// any (stepTemplate, the live page's: texts, and with the server's parts
|
|
@@ -830,7 +969,11 @@ function failedLineTemplate(record) {
|
|
|
830
969
|
// web.working_set): each block stamped with its
|
|
831
970
|
// turn's ordinal and its rows with theirs (working_set_view.js), and the
|
|
832
971
|
// turn's footprint on its summary line.
|
|
833
|
-
|
|
972
|
+
// +drawings+ (chi web's turn_drawings): a turn stamped with a drawn id is
|
|
973
|
+
// drawn from its drawing (drawnTurnNodes), its pieces marked data-drawn;
|
|
974
|
+
// +cards+ the snapshot's cards (a drawn card's body), +capabilities+ the
|
|
975
|
+
// page's (a notice shown elsewhere).
|
|
976
|
+
export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, now = Date.now(), drawings = null, cards = null, capabilities = {} }) {
|
|
834
977
|
const timingTemplate = (group) => {
|
|
835
978
|
const record = group.record;
|
|
836
979
|
// Numbered by its place in the turn records, as live (a failed turn
|
|
@@ -868,6 +1011,38 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
|
|
|
868
1011
|
return answerBubble(j, " report-reply", null, replyKey);
|
|
869
1012
|
};
|
|
870
1013
|
const steerTemplate = (i, bubble) => steerRowTemplate({ source: items[i].source, text: items[i].content }, { bubble });
|
|
1014
|
+
// A drawn turn's pieces (drawnTurnNodes), in the hand-off's order, each
|
|
1015
|
+
// top-level element marked with its turn (data-drawn: cards_place.js
|
|
1016
|
+
// leaves its in-turn entries alone, the reminder dedupe finds it).
|
|
1017
|
+
const drawnGroupPieces = (group, ordinal) => {
|
|
1018
|
+
const prompt = group.user != null ? items[group.user] : null;
|
|
1019
|
+
const drawn = drawnTurnNodes(group, items, { thumbs, capabilities, cards });
|
|
1020
|
+
if (drawn.block) {
|
|
1021
|
+
if (workingSet) drawn.block.querySelector(":scope > summary > .tw-sum")?.prepend(nodesOf(footprintTemplate(groupItems(group, ordinal), ordinal)));
|
|
1022
|
+
outlines.push(null);
|
|
1023
|
+
blockEnds.push(null);
|
|
1024
|
+
blockTurns.push(ordinal);
|
|
1025
|
+
}
|
|
1026
|
+
const lineKey = (i) => (items[i]?.source === "delegate_report" ? `${group.turnId}:input:${i - (group.user ?? i)}` : null);
|
|
1027
|
+
const reviewer = group.answer !== null && prompt?.turn_id && prompt.participant_mode !== PAIR ? authorBadge(prompt.author, prompt.author_name) : null;
|
|
1028
|
+
const pieces = turnPieces({
|
|
1029
|
+
prompt: prompt ? (prompt.role === "note" ? noteBubbleTemplate(prompt) : userTemplate(prompt)) : null,
|
|
1030
|
+
block: drawn.block,
|
|
1031
|
+
merged: drawn.extras.map((x) => (x.el ? x.el : x.merged != null
|
|
1032
|
+
? html`${userTemplate(items[x.merged], { state: items[x.merged].merged ? "steered" : null, step: items[x.merged].step, lineKey: lineKey(x.merged) })}${reportReplyTemplate(x.merged, group)}`
|
|
1033
|
+
: null)),
|
|
1034
|
+
answer: drawn.answer,
|
|
1035
|
+
timing: timingTemplate(group),
|
|
1036
|
+
apply: reviewer ? applyRowTemplate(prompt.turn_id, reviewer) : null,
|
|
1037
|
+
end: drawn.end,
|
|
1038
|
+
kept: drawn.kept,
|
|
1039
|
+
});
|
|
1040
|
+
return pieces.flatMap((piece) => {
|
|
1041
|
+
const els = piece?.nodeType === 1 ? [piece] : [...nodesOf(piece).children];
|
|
1042
|
+
els.forEach((el) => { el.dataset.drawn = group.drawn; });
|
|
1043
|
+
return els;
|
|
1044
|
+
});
|
|
1045
|
+
};
|
|
871
1046
|
const parts = [];
|
|
872
1047
|
// Each block's steps, in order: its outline once the nodes exist; how its
|
|
873
1048
|
// turn ended (whether it stays open); and its turn's place among the turns
|
|
@@ -876,7 +1051,7 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
|
|
|
876
1051
|
const blockEnds = [];
|
|
877
1052
|
const blockTurns = [];
|
|
878
1053
|
let turnOrdinal = -1;
|
|
879
|
-
const groups = turnGroups(items, timing);
|
|
1054
|
+
const groups = turnGroups(items, timing, { drawings });
|
|
880
1055
|
// A quiet stretch before a group: its divider first (time_gaps.js).
|
|
881
1056
|
const gaps = groupGaps(groups, items, now);
|
|
882
1057
|
for (const [k, group] of groups.entries()) {
|
|
@@ -887,9 +1062,13 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
|
|
|
887
1062
|
// A `!cmd`'s record: its command bubble, no steps, no timing.
|
|
888
1063
|
if (group.kind === "command") { parts.push(userTemplate(items[group.i])); continue; }
|
|
889
1064
|
turnOrdinal += 1;
|
|
1065
|
+
if (group.drawn) {
|
|
1066
|
+
parts.push(drawnGroupPieces(group, turnOrdinal));
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
890
1069
|
const prompt = items[group.user];
|
|
891
1070
|
// A context wake turn starts with its note.
|
|
892
|
-
|
|
1071
|
+
const promptPiece = prompt.role === "note" ? noteBubbleTemplate(prompt) : userTemplate(prompt);
|
|
893
1072
|
const [only] = group.steps;
|
|
894
1073
|
const ended = group.answer !== null || group.emptyAnswer != null;
|
|
895
1074
|
const textless = only && (only.i === null || !String(items[only.i].content ?? ""));
|
|
@@ -897,8 +1076,9 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
|
|
|
897
1076
|
// none): no block, the plain answer's collapsed thinking block.
|
|
898
1077
|
const plainThinking = group.steps.length === 1 && ended && textless && !only.tools.length && only.thinking;
|
|
899
1078
|
const noBlock = !group.steps.length || plainThinking;
|
|
1079
|
+
let block = null;
|
|
900
1080
|
if (plainThinking) {
|
|
901
|
-
|
|
1081
|
+
block = thinkingTemplate({ body: only.thinking, cls: "bubble thinking" });
|
|
902
1082
|
} else if (group.steps.length) {
|
|
903
1083
|
// A step's text with its message's rendered markdown (html: web.markdown on).
|
|
904
1084
|
const gens = group.steps.map((step) => ({
|
|
@@ -916,7 +1096,7 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
|
|
|
916
1096
|
// Its summary line: the working set's footprint, when the turn touched
|
|
917
1097
|
// something, then the steps (the live block's, stage_view.js handOff).
|
|
918
1098
|
const foot = workingSet ? footprintTemplate(groupItems(group, turnOrdinal), turnOrdinal) : nothing;
|
|
919
|
-
|
|
1099
|
+
block = html`<details class="turn-work done"><summary><span class="tw-sum">${foot}<span class="turn-steps">${model.blockSummary({ gens, ended: true })}</span></span></summary>${steps}</details>`;
|
|
920
1100
|
outlines.push(gens);
|
|
921
1101
|
// No record: a turn saved before them, or running at the reload.
|
|
922
1102
|
blockEnds.push(group.record?.status ?? "completed");
|
|
@@ -925,7 +1105,6 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
|
|
|
925
1105
|
// Steers with no step to sit in, or in a turn that shows no block: on
|
|
926
1106
|
// their own after the prompt.
|
|
927
1107
|
const loose = noBlock ? [...group.steps.flatMap((step) => step.steers || []), ...(group.steers || [])] : group.steers || [];
|
|
928
|
-
parts.push(loose.map((i) => steerTemplate(i, true)));
|
|
929
1108
|
// The user's lines merged into the turn: the live "steered" bubbles,
|
|
930
1109
|
// after its steps and before its answer. A delegate report's is
|
|
931
1110
|
// followed by the text that answered it when that isn't the answer: a
|
|
@@ -934,31 +1113,37 @@ export function turnHistoryNodes(items, timing, { thumbs, workingSet = false, no
|
|
|
934
1113
|
// A report's fold key (answer_fold.js), as its reply's: the turn's id
|
|
935
1114
|
// and its place after the opening; none in a turn saved before turn ids.
|
|
936
1115
|
const lineKey = (i) => (group.turnId && items[i]?.source === "delegate_report" ? `${group.turnId}:input:${i - group.user}` : null);
|
|
937
|
-
|
|
938
|
-
html`${userTemplate(items[i], { state: "steered", step: items[i].step, lineKey: lineKey(i) })}${reportReplyTemplate(i, group)}`)
|
|
939
|
-
// A turn that ended with no answer: chi's muted notice where the answer
|
|
940
|
-
// would be, before the timing, as live.
|
|
941
|
-
if (group.emptyAnswer != null) parts.push(html`${emptyAnswerTemplate(items[group.emptyAnswer])}${timingTemplate(group)}`);
|
|
942
|
-
// A turn with no answer (canceled, or running: no record yet) shows its
|
|
943
|
-
// timing after its prompt and steps, where the live view leaves it.
|
|
944
|
-
else if (group.answer === null) parts.push(timingTemplate(group));
|
|
945
|
-
else parts.push(answerTemplate(group.answer, timingTemplate(group), group.turnId));
|
|
1116
|
+
const merged = (group.merged || []).map((i) =>
|
|
1117
|
+
html`${userTemplate(items[i], { state: "steered", step: items[i].step, lineKey: lineKey(i) })}${reportReplyTemplate(i, group)}`);
|
|
946
1118
|
// A participant's review turn with a reply: Apply under it (the owner's
|
|
947
1119
|
// page only: the participant page has its own view). Not under a pair
|
|
948
1120
|
// turn: it acted itself.
|
|
949
1121
|
const reviewer = group.answer !== null && prompt.turn_id && prompt.participant_mode !== PAIR
|
|
950
1122
|
? authorBadge(prompt.author, prompt.author_name) : null;
|
|
951
|
-
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
|
|
955
|
-
|
|
1123
|
+
// The live hand-off's order (turn_pieces.js): a turn that ended with no
|
|
1124
|
+
// answer has chi's muted notice where the answer would be, before the
|
|
1125
|
+
// timing; a turn with no answer (canceled, or running: no record yet)
|
|
1126
|
+
// its timing after its prompt and steps. A canceled turn ends with its
|
|
1127
|
+
// cancel line, a failed one whose steps stayed with its failure line, as
|
|
1128
|
+
// the live view left them.
|
|
1129
|
+
parts.push(turnPieces({
|
|
1130
|
+
prompt: promptPiece,
|
|
1131
|
+
block,
|
|
1132
|
+
steers: loose.map((i) => steerTemplate(i, true)),
|
|
1133
|
+
merged,
|
|
1134
|
+
answer: group.emptyAnswer == null && group.answer !== null ? [answerBubble(group.answer, "", group.turnId)] : [],
|
|
1135
|
+
emptyNote: group.emptyAnswer != null ? emptyAnswerTemplate(items[group.emptyAnswer]) : null,
|
|
1136
|
+
timing: timingTemplate(group),
|
|
1137
|
+
apply: reviewer ? applyRowTemplate(prompt.turn_id, reviewer) : null,
|
|
1138
|
+
end: html`${cancelLineTemplate(group.record)}${failedLineTemplate(group.record)}`,
|
|
1139
|
+
}));
|
|
956
1140
|
}
|
|
957
1141
|
const out = nodesOf(parts);
|
|
958
1142
|
// A finished turn's block as the live end leaves it: its outline, closed
|
|
959
1143
|
// unless the turn was cut short.
|
|
960
1144
|
const blocks = [...out.children].filter((el) => el.matches(".turn-work"));
|
|
961
|
-
|
|
1145
|
+
// A drawn block is outlined and opened as its live end left it.
|
|
1146
|
+
blocks.forEach((block, i) => { if (outlines[i] !== null) block.open = openAfterEnd(blockEnds[i], outlineBlock(block, outlines[i] || [])); });
|
|
962
1147
|
if (workingSet) stampHistory(blocks, blockTurns);
|
|
963
1148
|
// Each answer bubble and step text knows its message's refs
|
|
964
1149
|
// (answer_refs.js; the page pairs them after the render), the index
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// A finished turn's drawing (chi web's turn_drawings: {events, end}, the
|
|
2
|
+
// Bridge's TurnDrawings unfolded once, in Ruby): what the live page drew for
|
|
3
|
+
// the turn, which a reload draws again (turn_block.js drawnTurnNodes feeds
|
|
4
|
+
// these events to a live turn block) instead of rebuilding the turn from its
|
|
5
|
+
// saved messages. Pure, no DOM: timing.js folds a drawing into its steps
|
|
6
|
+
// here (the working set's rows, the footprint), and a new module, so a
|
|
7
|
+
// cached older page never imports a name its modules lack.
|
|
8
|
+
import * as model from "./turn_model.js";
|
|
9
|
+
|
|
10
|
+
const END_KINDS = Object.freeze({ turn_completed: "completed", turn_canceled: "canceled", turn_failed: "failed" });
|
|
11
|
+
|
|
12
|
+
// How the drawn turn ended ("completed", "canceled", "failed"), from its end
|
|
13
|
+
// event's type.
|
|
14
|
+
export function drawnKind(end) {
|
|
15
|
+
return END_KINDS[end?.type] || "completed";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The drawing's events folded as turn_model.js folds a live turn (the same
|
|
19
|
+
// exports the turn block calls), ended: its gens, each with its tool rows.
|
|
20
|
+
export function drawingTurn(drawing) {
|
|
21
|
+
const turn = model.newTurn();
|
|
22
|
+
for (const event of drawing?.events || []) {
|
|
23
|
+
switch (event?.type) {
|
|
24
|
+
case "turn_started": model.turnStarted(turn); break;
|
|
25
|
+
case "generation_started": model.generationStarted(turn, event.iteration ?? null); break;
|
|
26
|
+
case "generation_chunk":
|
|
27
|
+
if (event.text || event.thinking) model.chunk(turn, { text: event.text || "", thinking: event.thinking || "", iteration: event.iteration ?? null });
|
|
28
|
+
break;
|
|
29
|
+
case "tool_call_started": model.toolStarted(turn, event); break;
|
|
30
|
+
case "tool_call_completed": model.toolCompleted(turn, event); break;
|
|
31
|
+
default: break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
model.turnEnded(turn);
|
|
35
|
+
return turn;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The ids of the drawn turns among +items+ (their `drawn` stamps, a
|
|
39
|
+
// drawing for each in +drawings+).
|
|
40
|
+
export function drawnIds(items, drawings) {
|
|
41
|
+
if (!drawings) return new Set();
|
|
42
|
+
return new Set((items || []).map((item) => item?.drawn).filter((id) => typeof id === "string" && drawings[id]));
|
|
43
|
+
}
|
|
@@ -346,6 +346,10 @@ export function replaysDrawnTurn(event, turnRecords = []) {
|
|
|
346
346
|
return turnRecords.some((record) => record.id === event.turn_id);
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
+
// REMOVE one release after chi web sends replay (see
|
|
350
|
+
// ~/.claude/plans/arch-turn-records.md): chi web's show sends these events
|
|
351
|
+
// itself (`replay`, TurnAccumulator.join_events); kept exported meanwhile, as
|
|
352
|
+
// an old cached app.js imports it from this module.
|
|
349
353
|
// A session view from the server (GET /api/sessions/:id on a live worker)
|
|
350
354
|
// as the live events that would have drawn it: the turn in progress (its
|
|
351
355
|
// parts in order), then the prompts queued behind it. app.js feeds these to
|
|
@@ -441,6 +445,10 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
|
|
|
441
445
|
closeText();
|
|
442
446
|
events.push({ type: "pending_input_merged", count: 0, content: null, steers: [{ source: part.source, text: part.text }] });
|
|
443
447
|
break;
|
|
448
|
+
// A join draws the turn's cards from the snapshot's (CardStore), as
|
|
449
|
+
// TurnAccumulator.replay_events leaves them out.
|
|
450
|
+
case "card":
|
|
451
|
+
break;
|
|
444
452
|
default:
|
|
445
453
|
break;
|
|
446
454
|
}
|
|
@@ -453,7 +461,8 @@ export function snapshotEvents({ current_turn: turn = null, queued = [], queued_
|
|
|
453
461
|
}
|
|
454
462
|
// Commands waiting for the turn's end: their bubbles, as live.
|
|
455
463
|
for (const entry of commands || []) {
|
|
456
|
-
|
|
464
|
+
const author = entry.author ? { author: entry.author } : {};
|
|
465
|
+
events.push({ type: "command_queued", command_id: entry.command_id, client_id: entry.client_id, line: entry.line, waits: "turn_end", ...author });
|
|
457
466
|
}
|
|
458
467
|
return events;
|
|
459
468
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// A finished turn's pieces in one order, and which step's text is its
|
|
2
|
+
// answer: the one rule the live hand-off (stage_model.js handOffOrder, the
|
|
3
|
+
// turn block's turnEnded) and a reload (turn_block.js turnHistoryNodes)
|
|
4
|
+
// both follow, so the two can't place a piece differently. Pure: the pieces
|
|
5
|
+
// are whatever the caller draws (nodes, lit templates); a missing one is
|
|
6
|
+
// skipped.
|
|
7
|
+
|
|
8
|
+
// The turn's pieces in order: its prompt (none for a reminder turn), its
|
|
9
|
+
// block of steps, the steers no step holds, the lines merged into it in
|
|
10
|
+
// arrival order (live: every extra, cards too), the answer's pieces or the
|
|
11
|
+
// notice of a turn with none, the timing line, a participant's review Apply,
|
|
12
|
+
// the end line (cancel, failure), the kept cards, then the lines queued for
|
|
13
|
+
// after the turn (they run next).
|
|
14
|
+
export function turnPieces({ prompt = null, block = null, steers = [], merged = [], answer = [], emptyNote = null, timing = null,
|
|
15
|
+
apply = null, end = null, kept = [], queued = [] } = {}) {
|
|
16
|
+
return [prompt, block, ...steers, ...merged, ...answer, emptyNote, timing, apply, end, ...kept, ...queued].filter(Boolean);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// The generation whose text is +turn+'s answer when it ended as +kind+
|
|
20
|
+
// ("completed", "canceled", "failed"), else null: its current (last, still
|
|
21
|
+
// open) generation, for a completed turn always, for any turn of one step
|
|
22
|
+
// without tools too (canceled, it is the partial text). +turn+: turn_model.js's
|
|
23
|
+
// {gens: [{tools, closed}]}.
|
|
24
|
+
export function answerGen(turn, kind) {
|
|
25
|
+
const gens = turn?.gens || [];
|
|
26
|
+
const last = gens[gens.length - 1];
|
|
27
|
+
if (!last || last.closed) return null;
|
|
28
|
+
return kind === "completed" || (gens.length === 1 && last.tools.length === 0) ? last : null;
|
|
29
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../log"
|
|
4
|
+
require_relative "../worker_link"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module Web
|
|
8
|
+
# POST /api/sessions/:id/reload_check, mixed into App: the opt-in reload
|
|
9
|
+
# check's report (web.reload_check; the page's reload_check.js compares a
|
|
10
|
+
# finished turn as it was drawn live with a reload of it, history_shape.js).
|
|
11
|
+
# One log line per check, counts only, never content: reload_checked,
|
|
12
|
+
# and reload_mismatch with the kinds that differed and the ACCEPTED
|
|
13
|
+
# entries that would excuse them (the page's check is strict). The pod count is
|
|
14
|
+
# `grep -c reload_mismatch` over `grep -c reload_checked`, split by
|
|
15
|
+
# kinds, drawn and worker_version. See docs/configuration.md.
|
|
16
|
+
module ReloadCheckRoute
|
|
17
|
+
# history_shape.js KINDS: the only tokens a kinds list may carry
|
|
18
|
+
# (spec/web/reload_check_route_spec.rb reads the JS list).
|
|
19
|
+
KINDS = %w[prompt steered note command recap block step steer row notice card question thinking answer
|
|
20
|
+
empty-answer timing cancel failed other].freeze
|
|
21
|
+
# history_shape.js ACCEPTED's ids: the only tokens an excused list may
|
|
22
|
+
# carry (the same spec reads the JS list).
|
|
23
|
+
ACCEPTED_IDS = %w[command-bubble edit-mark].freeze
|
|
24
|
+
# A turn id the line may name (a uuid, a test's id); anything else is
|
|
25
|
+
# dropped.
|
|
26
|
+
TURN_ID = /\A[0-9A-Za-z_-]{1,64}\z/
|
|
27
|
+
|
|
28
|
+
# The turns already checked, by session and turn id: every open owner
|
|
29
|
+
# tab runs the check, and only the first one's report is logged (a
|
|
30
|
+
# second tab's is answered 204 and dropped). Bounded: the oldest go
|
|
31
|
+
# first past LIMIT; a restarted chi web starts over.
|
|
32
|
+
class Seen
|
|
33
|
+
LIMIT = 2000
|
|
34
|
+
|
|
35
|
+
def initialize
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
@keys = {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Whether +key+ is new (and now seen).
|
|
41
|
+
def first?(key)
|
|
42
|
+
@mutex.synchronize do
|
|
43
|
+
next false if @keys.key?(key)
|
|
44
|
+
|
|
45
|
+
@keys[key] = true
|
|
46
|
+
@keys.shift while @keys.size > LIMIT
|
|
47
|
+
true
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# {turn_id, same, kinds, excused, explained, drawn} from the page: 204,
|
|
55
|
+
# whatever it said
|
|
56
|
+
# (fire-and-forget); 400 for a body that isn't a JSON object.
|
|
57
|
+
# worker_version is chi web's own view of the session's worker (its
|
|
58
|
+
# sidecar), never the body's.
|
|
59
|
+
def handle_reload_check(req, id)
|
|
60
|
+
body = parse_json(request_body(req, 4096))
|
|
61
|
+
return error_response(400, "invalid_json", "invalid JSON body") unless body.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
turn_id = body["turn_id"].to_s.match?(TURN_ID) ? body["turn_id"] : nil
|
|
64
|
+
# Another tab's report of a turn already counted.
|
|
65
|
+
return [204, { "Cache-Control" => "no-store" }, []] if turn_id && !@reload_checks.first?([id, turn_id])
|
|
66
|
+
|
|
67
|
+
same = body["same"] == true
|
|
68
|
+
drawn = body["drawn"] == true
|
|
69
|
+
fields = { sid: id, turn_id: turn_id, same: same, drawn: drawn, worker_version: checked_worker_version(id) }
|
|
70
|
+
Log.info(:web, "reload_checked", **fields)
|
|
71
|
+
unless same
|
|
72
|
+
Log.info(:web, "reload_mismatch", **fields.except(:same), kinds: checked_tokens(body["kinds"], KINDS),
|
|
73
|
+
excused: checked_tokens(body["excused"], ACCEPTED_IDS),
|
|
74
|
+
explained: body["explained"] == true)
|
|
75
|
+
end
|
|
76
|
+
[204, { "Cache-Control" => "no-store" }, []]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# +tokens+ as a sorted comma list of +known+'s, the rest dropped.
|
|
80
|
+
def checked_tokens(tokens, known)
|
|
81
|
+
Array(tokens).map(&:to_s).select { |token| known.include?(token) }.uniq.sort.join(",")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The version of the chi the session's live worker runs (its
|
|
85
|
+
# sidecar), nil without one.
|
|
86
|
+
def checked_worker_version(id)
|
|
87
|
+
dir = @session_class.session_dir(id, state_dir: default_state_dir)
|
|
88
|
+
link = WorkerLink.resolve(dir, unlink: false)
|
|
89
|
+
link.live? ? link.sidecar&.version : nil
|
|
90
|
+
rescue StandardError
|
|
91
|
+
nil # degrade-ok: a line without the version is still counted
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -100,6 +100,7 @@ module Samagotchi
|
|
|
100
100
|
annotate_presets: Config::BY_KEY["web.annotate_presets"].default,
|
|
101
101
|
editor: Config::BY_KEY["web.editor"].default, new_token: false,
|
|
102
102
|
working_set: Config::BY_KEY["web.working_set"].default, tunnel: nil, start_dir_from: :flag,
|
|
103
|
+
reload_check: Config::BY_KEY["web.reload_check"].default,
|
|
103
104
|
restarted: false, restart_argv: ["web"], exec: nil)
|
|
104
105
|
handed_on = false # a :restart leaves Serve to the next process, a run turns it off itself
|
|
105
106
|
start_given = !start_dir.to_s.strip.empty?
|
|
@@ -159,7 +160,7 @@ module Samagotchi
|
|
|
159
160
|
Push.preflight!(public_url)
|
|
160
161
|
options = { port: port, host: host, url: url, open_browser: open_browser, markdown: markdown,
|
|
161
162
|
annotate_presets: annotate_presets, editor: editor, lan: lan, working_set: working_set,
|
|
162
|
-
public_url: public_url, start_dir: start_dir }
|
|
163
|
+
reload_check: reload_check, public_url: public_url, start_dir: start_dir }
|
|
163
164
|
started = if plan
|
|
164
165
|
with_tunnel(provider, plan) do |up|
|
|
165
166
|
start(**options, public_notes: provider.start_lines(plan, restarted: restarted), on_bound: up)
|
|
@@ -388,6 +389,7 @@ module Samagotchi
|
|
|
388
389
|
annotate_presets: Config::BY_KEY["web.annotate_presets"].default, hub: nil, lan: nil,
|
|
389
390
|
editor: Config::BY_KEY["web.editor"].default, token_path: Token.path,
|
|
390
391
|
working_set: Config::BY_KEY["web.working_set"].default, public_url: nil, public_notes: [],
|
|
392
|
+
reload_check: Config::BY_KEY["web.reload_check"].default,
|
|
391
393
|
on_bound: nil, start_dir: Dir.pwd, update_check: nil, auto_update: nil)
|
|
392
394
|
config_restart_unsubscribe = ConfigFile.watch_restart_settings!(process: :web) do |message|
|
|
393
395
|
Samagotchi::Log.warn(:config, "restart_required", echo: "Warning: config: #{message}")
|
|
@@ -404,7 +406,7 @@ module Samagotchi
|
|
|
404
406
|
push = push_runtime(public_url, state_dir)
|
|
405
407
|
app = App.new(manager: manager, state_dir: state_dir, markdown: markdown,
|
|
406
408
|
annotate_presets: annotate_presets, editor: editor, hub: hub, lan: lan_option, working_set: working_set,
|
|
407
|
-
public_url: public_url, public_token: public_token, push: push, start_dir: start_dir)
|
|
409
|
+
reload_check: reload_check, public_url: public_url, public_token: public_token, push: push, start_dir: start_dir)
|
|
408
410
|
# update.auto: install; a stand-in app (a spec's) has no operations to run.
|
|
409
411
|
auto_update ||= AutoUpdate.new(hub: hub, ops: app.update_ops, check: update_check, preflight: method(:restart_preflight)) if app.respond_to?(:update_ops)
|
|
410
412
|
webrick = nil
|
|
@@ -109,8 +109,8 @@ module Samagotchi
|
|
|
109
109
|
end
|
|
110
110
|
|
|
111
111
|
# GET /api/sessions/summaries: the rows chi sessions list prints
|
|
112
|
-
# (
|
|
113
|
-
# ctx_pct, waiting, …), filtered as its flags filter. Params: dir (a
|
|
112
|
+
# (SessionRow#to_summary_h, its 28 keys: status, live, busy, owner,
|
|
113
|
+
# recap, ctx_pct, waiting, …), filtered as its flags filter. Params: dir (a
|
|
114
114
|
# folder: its project's sessions; none: every project's), cwd (in that
|
|
115
115
|
# folder or below), live=1, archived=1, tests=0 (leave test runs out),
|
|
116
116
|
# sort, order, limit. An array, newest first by default.
|
|
@@ -128,7 +128,7 @@ module Samagotchi
|
|
|
128
128
|
project_root: dir && ProjectScope.root_for(dir), include_archived: truthy_param?(req.params["archived"]),
|
|
129
129
|
sort: sanitize_sort(req.params["sort"]), order: sanitize_order(req.params["order"])
|
|
130
130
|
)
|
|
131
|
-
json_response(200, rows)
|
|
131
|
+
json_response(200, rows.map(&:to_summary_h))
|
|
132
132
|
end
|
|
133
133
|
|
|
134
134
|
# POST /api/sessions/:id/wait/prepare: what chi send --wait and chi
|
|
@@ -207,7 +207,7 @@ module Samagotchi
|
|
|
207
207
|
when :accepted
|
|
208
208
|
record_history(body, prompt)
|
|
209
209
|
# via: bridge or file (the worker was gone: its input file), which
|
|
210
|
-
# names the turn a remote chi send --wait follows (
|
|
210
|
+
# names the turn a remote chi send --wait follows (ReplyWait::Target).
|
|
211
211
|
json_response(202, result.ack.merge("via" => result.via.to_s))
|
|
212
212
|
when :refused then json_response(result.code, result.ack)
|
|
213
213
|
when :timeout then error_response(504, "worker_timeout", result.detail)
|