samagotchi 0.102.0 → 0.103.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 +40 -1
- data/bin/chi +23 -191
- data/docs/architecture.md +54 -0
- data/docs/cli.md +114 -7
- data/docs/configuration.md +7 -1
- data/docs/guardrails.md +2 -1
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-forget.md +11 -1
- data/docs/internals/remote-sessions.md +303 -0
- data/docs/internals/working-set.md +95 -11
- data/docs/plugins.md +26 -2
- data/docs/sessions.md +12 -5
- data/docs/sub-agent.md +8 -0
- data/lib/samagotchi/answer_command.rb +27 -25
- data/lib/samagotchi/bridge.rb +24 -4
- data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
- data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
- data/lib/samagotchi/bridge_client.rb +22 -38
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_invocation.rb +43 -0
- data/lib/samagotchi/cli/command.rb +3 -2
- data/lib/samagotchi/cli/flags.rb +5 -0
- data/lib/samagotchi/cli/parent_wait.rb +22 -13
- data/lib/samagotchi/context_command.rb +80 -73
- data/lib/samagotchi/engine.rb +12 -8
- data/lib/samagotchi/idle_recap.rb +7 -1
- data/lib/samagotchi/kernel_loop.rb +13 -1
- data/lib/samagotchi/launch_mode.rb +22 -0
- data/lib/samagotchi/llm_context_forget.rb +13 -6
- data/lib/samagotchi/note_command.rb +8 -7
- data/lib/samagotchi/parent_report.rb +55 -43
- data/lib/samagotchi/remote_command.rb +170 -0
- data/lib/samagotchi/remotes.rb +130 -0
- data/lib/samagotchi/run_options.rb +233 -0
- data/lib/samagotchi/send_command/busy.rb +58 -0
- data/lib/samagotchi/send_command/parent_gate.rb +23 -10
- data/lib/samagotchi/send_command/wait_target.rb +16 -3
- data/lib/samagotchi/send_command.rb +173 -108
- data/lib/samagotchi/session.rb +30 -5
- data/lib/samagotchi/session_access/contract.rb +129 -0
- data/lib/samagotchi/session_access/dispatch.rb +133 -0
- data/lib/samagotchi/session_access/http_transport.rb +109 -0
- data/lib/samagotchi/session_access/local.rb +349 -0
- data/lib/samagotchi/session_access/remote.rb +638 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
- data/lib/samagotchi/session_access.rb +141 -0
- data/lib/samagotchi/session_archive_command.rb +9 -5
- data/lib/samagotchi/session_commands.rb +20 -15
- data/lib/samagotchi/session_delete_command.rb +8 -4
- data/lib/samagotchi/session_manager.rb +7 -2
- data/lib/samagotchi/session_setup.rb +11 -6
- data/lib/samagotchi/sessions_command.rb +36 -14
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +51 -15
- data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +80 -75
- data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
- data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
- data/lib/samagotchi/terminal_ui.rb +2 -2
- data/lib/samagotchi/title_command.rb +9 -11
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +360 -69
- data/lib/samagotchi/web/public/data.js +3 -2
- data/lib/samagotchi/web/public/index.html +20 -4
- data/lib/samagotchi/web/public/stage_frame.js +7 -2
- data/lib/samagotchi/web/public/stage_view.js +3 -2
- data/lib/samagotchi/web/public/stream_handlers.js +3 -1
- data/lib/samagotchi/web/public/working_set.js +159 -28
- data/lib/samagotchi/web/public/working_set_rail.js +39 -12
- data/lib/samagotchi/web/public/working_set_view.js +91 -33
- data/lib/samagotchi/web/session_access_routes.rb +343 -0
- data/lib/samagotchi/worker_sidecar.rb +12 -5
- data/lib/samagotchi/working_set.rb +88 -14
- data/lib/samagotchi/working_set_command.rb +133 -30
- data/lib/samagotchi/working_set_pin.rb +34 -0
- data/lib/samagotchi/working_set_steer.rb +191 -27
- metadata +19 -1
|
@@ -5,10 +5,19 @@
|
|
|
5
5
|
// and the step tags' jump to a call. The rail is working_set_rail.js's; the
|
|
6
6
|
// rules are working_set.js's.
|
|
7
7
|
//
|
|
8
|
-
// Steering (
|
|
9
|
-
// `/working-set drop|undrop KEY…` lines (+sendLine+, the session
|
|
10
|
-
// route).
|
|
11
|
-
//
|
|
8
|
+
// Steering (drop / undrop, pin / unpin, + add): the list's rows and groups post
|
|
9
|
+
// `/working-set drop|undrop|pin|unpin|add KEY…` lines (+sendLine+, the session
|
|
10
|
+
// command route). The list's foot (the sheet's last row) adds what the user
|
|
11
|
+
// types (a path, memory:NAME or a URL); a stale or forgotten row offers
|
|
12
|
+
// refresh (an add of it). An add is pending until the next turn sends its
|
|
13
|
+
// note: the server's snapshot lists it (adds) between turns, a queued line
|
|
14
|
+
// mid-turn; an added item the set doesn't hold yet is a `+ name` row with
|
|
15
|
+
// `reads next turn` and an undo (unadd). Between turns one runs at once: its command_ran re-reads the
|
|
16
|
+
// conversation and the re-emitted set strikes the item `dropped` (or marks
|
|
17
|
+
// it pinned). A row offers pin only when the session's strategy has the
|
|
18
|
+
// forget layer (forgetOn: pins guard against the model's forget); a pinned
|
|
19
|
+
// row shows its 📌 and unpin under any strategy, since its drop waits for
|
|
20
|
+
// the unpin. While a
|
|
12
21
|
// turn runs it is queued: its command_queued (waits: "turn_end"; any
|
|
13
22
|
// client's, and the snapshot's replay after a reload) makes it pending, a
|
|
14
23
|
// dashed-warn row with a pill and an undo (the inverse line, queued after
|
|
@@ -32,8 +41,9 @@ import { html, nothing, render, svg } from "./vendor/lit-html.js";
|
|
|
32
41
|
import { createChipPopover } from "./chip_popover.js";
|
|
33
42
|
import { showRow } from "./refs.js";
|
|
34
43
|
import {
|
|
35
|
-
BUDGETS, dropRefusal, fold, footprint, footprintText, GLYPHS, INVERSE, isFresh, itemsOfTurn, labelText,
|
|
36
|
-
opsText, overlay, parseSteerLine, pendingSteers,
|
|
44
|
+
addedItems, addLine, BUDGETS, dropRefusal, fold, footprint, footprintText, forgetOn, GLYPHS, INVERSE, isFresh, itemsOfTurn, labelText,
|
|
45
|
+
labelTitle, newerSnapshot, opsText, overlay, parseSteerLine, pendingSteers, pinnable, refreshable, serverAddIds, sessionItems, STEER_OPS,
|
|
46
|
+
steerId, steerLine, stepRange, stepTag, tokensText, undroppable,
|
|
37
47
|
} from "./working_set.js";
|
|
38
48
|
|
|
39
49
|
// The popover's files fold to this many lines (groups open in place, one at
|
|
@@ -194,38 +204,69 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
194
204
|
if (opening) fetchServer();
|
|
195
205
|
},
|
|
196
206
|
onClose: () => { openGroup = null; onChange(); },
|
|
207
|
+
// The foot's + add (the popover's submit): one KEY as typed (quoted
|
|
208
|
+
// when it has spaces).
|
|
209
|
+
onSubmit: (form) => {
|
|
210
|
+
if (!form?.matches?.(".ws-add")) return;
|
|
211
|
+
const input = form.querySelector(".ws-add-in");
|
|
212
|
+
const line = sendLine && addLine(input?.value);
|
|
213
|
+
if (!line) return;
|
|
214
|
+
input.value = "";
|
|
215
|
+
Promise.resolve(sendLine(line)).catch(() => {});
|
|
216
|
+
},
|
|
197
217
|
});
|
|
198
218
|
|
|
199
|
-
|
|
219
|
+
// The pending ops: the server's adds (made between turns), then the
|
|
220
|
+
// queued lines.
|
|
221
|
+
const pending = () => pendingSteers([...queued.values()], state().dir, serverAddIds(server));
|
|
222
|
+
// The pending adds the set doesn't hold yet, as rows.
|
|
223
|
+
const adding = (pend = pending()) => addedItems(pend, sessionSet().items, server, state().dir);
|
|
200
224
|
|
|
201
225
|
// A steer button: +op+ on +items+ ("undo" sends the inverse of a pending op).
|
|
202
226
|
function actButton(op, items, { label = op, title = "", disabled = false } = {}) {
|
|
203
|
-
|
|
227
|
+
// JSON: an id may hold spaces (a file under "my notes/").
|
|
228
|
+
const ids = JSON.stringify(items.map(steerId));
|
|
204
229
|
return html`<button type="button" class=${`ws-act-b ${op}`} data-ws="steer" data-op=${op} data-ids=${ids} title=${title} ?disabled=${disabled}>${label}</button>`;
|
|
205
230
|
}
|
|
206
231
|
|
|
232
|
+
// A pending op's pill.
|
|
233
|
+
const PILLS = { drop: "drops next turn", undrop: "back next turn", pin: "applies next turn", unpin: "applies next turn",
|
|
234
|
+
add: "reads next turn" };
|
|
235
|
+
|
|
207
236
|
// What a row offers: a pending op's pill and undo, an undrop, a drop (or
|
|
208
|
-
// why not); nothing for an item no op applies to.
|
|
209
|
-
function rowActions(item, pend) {
|
|
237
|
+
// why not), a pin or an unpin; nothing for an item no op applies to.
|
|
238
|
+
function rowActions(item, pend, forget) {
|
|
210
239
|
if (!sendLine) return { pill: nothing, buttons: nothing };
|
|
211
|
-
|
|
240
|
+
let op = pend.get(steerId(item));
|
|
241
|
+
// A drop queued for a pinned item: the worker refuses it (unpin it
|
|
242
|
+
// first), so it isn't pending; the row offers its greyed drop.
|
|
243
|
+
if (op === "drop" && item.pinned) op = undefined;
|
|
212
244
|
if (op) {
|
|
213
|
-
const
|
|
245
|
+
const when = op === "add" ? "the next turn's note asks the model to read it first" : "applies at the next turn's start";
|
|
246
|
+
const pill = html`<span class="ws-pend" title=${when}>${PILLS[op]}</span>`;
|
|
214
247
|
return { pill, buttons: actButton(INVERSE[op], [item], { label: "undo", title: `take back the queued ${op}` }) };
|
|
215
248
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
249
|
+
const undrop = actButton("undrop", [item], { title: "send it whole again from the next request (a cache break)" });
|
|
250
|
+
// Dropped, or a drop on some reads with nothing else of it sent (the
|
|
251
|
+
// model's layers stubbed the rest): undrop is all it offers.
|
|
252
|
+
if (item.state === "dropped" || (undroppable(item) && item.state === "forgotten")) return { pill: nothing, buttons: undrop };
|
|
253
|
+
const buttons = [];
|
|
219
254
|
const why = dropRefusal(item);
|
|
220
|
-
if (why
|
|
221
|
-
|
|
255
|
+
if (why !== null) buttons.push(actButton("drop", [item], { title: why || "send a stub instead of its reads from the next turn's start", disabled: !!why }));
|
|
256
|
+
if (item.pinned) buttons.push(actButton("unpin", [item], { title: "the model's forget_outputs may forget it again" }));
|
|
257
|
+
else if (forget && pinnable(item)) buttons.push(actButton("pin", [item], { title: "keep it from the model's forget_outputs (its later reads too)" }));
|
|
258
|
+
// A drop on some of its reads, others still sent: undrop takes it back.
|
|
259
|
+
if (undroppable(item)) buttons.push(undrop);
|
|
260
|
+
if (refreshable(item)) buttons.push(actButton("add", [item], { label: "refresh", title: "the next turn's note asks the model to read it again" }));
|
|
261
|
+
return { pill: nothing, buttons: buttons.length ? buttons : nothing };
|
|
222
262
|
}
|
|
223
263
|
|
|
224
|
-
function rowTemplate(item, { session: inSession, group = null, now, pend = new Map() }) {
|
|
264
|
+
function rowTemplate(item, { session: inSession, group = null, now, pend = new Map(), forget = false }) {
|
|
225
265
|
const fresh = isFresh(seenAt(item), now);
|
|
226
266
|
const tag = stepTag(item, { session: inSession });
|
|
227
|
-
|
|
228
|
-
const
|
|
267
|
+
// An added row's words: what it is (a phone's tap on them opens its strip).
|
|
268
|
+
const words = item.added ? "added" : `${opsText(item)}${fresh ? " · just now" : ""}`;
|
|
269
|
+
const glyph = item.added ? "+" : item.muted ? GLYPHS.muted : fresh ? "●" : GLYPHS[item.kind] || "";
|
|
229
270
|
const name = memberName(item, group);
|
|
230
271
|
const full = item.kind === "file" ? item.path || item.key : item.kind === "url" ? item.url || item.key : item.key;
|
|
231
272
|
let nameEl;
|
|
@@ -248,11 +289,12 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
248
289
|
const state = item.state && item.state !== "live" ? ` ${item.state}` : "";
|
|
249
290
|
const tokens = tokensText(item.tokens);
|
|
250
291
|
const id = steerId(item);
|
|
251
|
-
const { pill, buttons } = rowActions(item, pend);
|
|
292
|
+
const { pill, buttons } = rowActions(item, pend, forget);
|
|
293
|
+
const pin = item.pinned ? html`<span class="ws-pin" title="pinned: the model's forget_outputs refuses it">📌</span>` : nothing;
|
|
252
294
|
const isPending = pill !== nothing;
|
|
253
295
|
const strip = stripOpen === id && buttons !== nothing;
|
|
254
296
|
const open = item.kind === "file" ? html`<button type="button" class="ws-act-b open ref" data-ref-kind="file" data-ref-path=${item.ref?.path || full}>open</button>` : nothing;
|
|
255
|
-
const row = html`<div class=${`ws-row ${item.kind}${fresh ? " fresh" : ""}${item.muted ? " muted" : ""}${group ? " member" : ""}${state}${isPending ? " pending" : ""}${strip ? " strip-open" : ""}`} data-ws-id=${id}><span class="k">${glyph}</span>${nameEl}${edited ? html`<span class="e">✎</span>` : nothing}<span class="st">${words}</span>${pill}${tokens ? html`<span class="tk" title=${`about ${item.tokens} tokens of it are sent`}>${tokens}</span>` : nothing}${tagEl}${buttons !== nothing ? html`<span class="ws-act">${buttons}</span>` : nothing}</div>`;
|
|
297
|
+
const row = html`<div class=${`ws-row ${item.kind}${item.added ? " added" : ""}${fresh ? " fresh" : ""}${item.muted ? " muted" : ""}${group ? " member" : ""}${state}${isPending ? " pending" : ""}${strip ? " strip-open" : ""}`} data-ws-id=${id}><span class="k">${glyph}</span>${nameEl}${pin}${edited ? html`<span class="e">✎</span>` : nothing}<span class="st">${words}</span>${pill}${tokens ? html`<span class="tk" title=${`about ${item.tokens} tokens of it are sent`}>${tokens}</span>` : nothing}${tagEl}${buttons !== nothing ? html`<span class="ws-act">${buttons}</span>` : nothing}</div>`;
|
|
256
298
|
// One template either way, so the tapped row stays the same node (a
|
|
257
299
|
// click outside the popover closes it).
|
|
258
300
|
return html`${row}${strip ? html`<div class="ws-strip">${buttons}${open}</div>` : nothing}`;
|
|
@@ -282,7 +324,8 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
282
324
|
const memories = set.items.filter((i) => i.kind === "memory");
|
|
283
325
|
const urls = set.items.filter((i) => i.kind === "url");
|
|
284
326
|
const pend = pending();
|
|
285
|
-
const
|
|
327
|
+
const forget = forgetOn(st.llmContext);
|
|
328
|
+
const opts = { session: inSession, now, pend, forget };
|
|
286
329
|
const fileLabels = fold(files, POP_BUDGET).labels;
|
|
287
330
|
// Memories are rows of their own; past the budget one group that opens.
|
|
288
331
|
const memoryLabels = fold(memories, memories.length > POP_BUDGET ? 1 : Infinity).labels;
|
|
@@ -290,14 +333,17 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
290
333
|
const memMeta = [`${memories.length - preloaded} used`, preloaded ? `${preloaded} preloaded` : "", set.muted.length ? `${set.muted.length} muted` : ""].filter(Boolean).join(" · ");
|
|
291
334
|
const turnLabel = view.turn !== null && view.turn >= 0 ? `turn ${view.turn + 1}` : "this turn";
|
|
292
335
|
const pct = Number.isFinite(st.ctxPct) ? ` · ctx ${Math.round(st.ctxPct)} %` : "";
|
|
293
|
-
const
|
|
336
|
+
const added = sendLine ? adding(pend) : [];
|
|
337
|
+
const empty = !set.items.length && !set.muted.length && !added.length;
|
|
294
338
|
const tpl = html`<div class="ctx-pop-head"><strong>working set</strong><span class="ws-scope" role="group" aria-label="Scope"><button type="button" class=${!inSession ? "on" : ""} aria-pressed=${String(!inSession)} data-ws="scope" data-scope="turn" ?disabled=${view.turn === null || view.turn < 0}>${turnLabel}</button><button type="button" class=${inSession ? "on" : ""} aria-pressed=${String(inSession)} data-ws="scope" data-scope="session">session</button></span><span class="ctx-pop-kind">${set.items.length} item${set.items.length === 1 ? "" : "s"}${pct}</span>${ringTemplate(st.ctxPct)}<button type="button" class="ctx-pop-close" data-act="close" aria-label="Close">×</button></div>
|
|
295
339
|
${empty ? html`<div class="ctx-pop-meta">Nothing yet: the files, memories and URLs ${inSession ? "this session's turns read" : "this turn reads"} show here.</div>` : nothing}
|
|
296
340
|
${files.length ? html`<div class="ctx-pop-meta">files · ${files.length}</div>${labelsTemplate(fileLabels, opts)}` : nothing}
|
|
297
341
|
${memories.length || set.muted.length ? html`<div class="ctx-pop-meta">memories${memMeta ? ` · ${memMeta}` : ""}</div>${labelsTemplate(memoryLabels, opts)}${set.muted.map((m) => rowTemplate(m, opts))}` : nothing}
|
|
298
342
|
${urls.length ? html`<div class="ctx-pop-meta">urls · ${urls.length}</div>${labelsTemplate(fold(urls, POP_BUDGET).labels, opts)}` : nothing}
|
|
299
|
-
${
|
|
300
|
-
|
|
343
|
+
${added.length ? html`<div class="ctx-pop-meta">adding · ${added.length}</div>${added.map((it) => rowTemplate(it, opts))}` : nothing}
|
|
344
|
+
${pend.size ? html`<div class="ws-pending" role="status"><span class="ws-pending-t">${pend.size} change${pend.size === 1 ? "" : "s"} pending<span class="ws-pending-when"> · apply at the next turn's start</span></span><button type="button" class="ws-act-b" data-ws="undo-all">undo all</button></div>` : nothing}
|
|
345
|
+
${sendLine ? html`<form class="ws-add" data-ws="add-form"><input class="ws-add-in" type="text" name="key" placeholder="+ path, memory:NAME or URL" aria-label="Add to the working set: a path, memory:NAME or a URL" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="go"><button type="submit" class="ws-act-b">add</button></form>` : nothing}
|
|
346
|
+
<div class="ctx-pop-meta ws-hint">A file opens its ref; a step tag goes to the call.${sendLine ? " Drop sends stubs instead of an item's reads from the next turn; add asks the model to read one first." : ""}${sendLine && forget ? " Pin keeps an item from the model's forget." : ""} Esc closes.</div>`;
|
|
301
347
|
render(tpl, popover.pop);
|
|
302
348
|
}
|
|
303
349
|
|
|
@@ -329,10 +375,10 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
329
375
|
// Posts +op+ for the ids (the items' steer words, from the set).
|
|
330
376
|
function steer(op, ids) {
|
|
331
377
|
if (!sendLine || !ids.length) return;
|
|
332
|
-
const all = [...sessionSet().items, ...turnItems(view.turn)];
|
|
378
|
+
const all = [...sessionSet().items, ...turnItems(view.turn), ...adding()];
|
|
333
379
|
const byId = new Map(all.map((it) => [steerId(it), it]));
|
|
334
380
|
const items = ids.map((id) => byId.get(id)).filter(Boolean);
|
|
335
|
-
if (items.length) Promise.resolve(sendLine(steerLine(op, items))).catch(() => {});
|
|
381
|
+
if (items.length) Promise.resolve(sendLine(steerLine(op, items, state().dir))).catch(() => {});
|
|
336
382
|
}
|
|
337
383
|
|
|
338
384
|
popover.pop.addEventListener("click", (e) => {
|
|
@@ -349,10 +395,10 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
349
395
|
}
|
|
350
396
|
if (!act) return;
|
|
351
397
|
if (act.dataset.ws === "steer") {
|
|
352
|
-
steer(act.dataset.op, act.dataset.ids
|
|
398
|
+
steer(act.dataset.op, JSON.parse(act.dataset.ids || "[]"));
|
|
353
399
|
} else if (act.dataset.ws === "undo-all") {
|
|
354
400
|
const pend = pending();
|
|
355
|
-
for (const op of
|
|
401
|
+
for (const op of STEER_OPS) {
|
|
356
402
|
const ids = [...pend].filter(([, o]) => o === op).map(([id]) => id);
|
|
357
403
|
steer(INVERSE[op], ids);
|
|
358
404
|
}
|
|
@@ -398,6 +444,12 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
398
444
|
if (popover.isOpen()) popover.refresh();
|
|
399
445
|
}
|
|
400
446
|
|
|
447
|
+
// The pending ops changed: the list and the rail's marks.
|
|
448
|
+
function pendingChanged() {
|
|
449
|
+
changed();
|
|
450
|
+
for (const fn of listeners) fn();
|
|
451
|
+
}
|
|
452
|
+
|
|
401
453
|
// The footprints on the page (the history's, the hand-off's) drawn again
|
|
402
454
|
// in place when their items' states changed: the same node (its focus,
|
|
403
455
|
// the popover's opener), its children and label replaced.
|
|
@@ -466,12 +518,18 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
466
518
|
refreshFootprints();
|
|
467
519
|
fetchServer();
|
|
468
520
|
},
|
|
521
|
+
// The session's strategy changed (/llm-context's command_ran): the open
|
|
522
|
+
// list redraws, its pin buttons shown only under forget (forgetOn).
|
|
523
|
+
strategyChanged() {
|
|
524
|
+
if (popover.isOpen()) popover.refresh();
|
|
525
|
+
},
|
|
469
526
|
// A :working_set_updated event's set ({items, at}).
|
|
470
527
|
update(snapshot) {
|
|
471
528
|
events += 1;
|
|
472
529
|
takeServer(snapshot, { event: true });
|
|
473
530
|
},
|
|
474
|
-
// +fn()+ runs after each new server snapshot
|
|
531
|
+
// +fn()+ runs after each new server snapshot and each change of the
|
|
532
|
+
// pending ops (the rail's redraw).
|
|
475
533
|
onServer(fn) { listeners.add(fn); },
|
|
476
534
|
// A command queued for after the running turn (its command_queued,
|
|
477
535
|
// waits "turn_end"): a steer line is pending until its command_ran.
|
|
@@ -482,11 +540,11 @@ export function createWorkingSet({ historyEl, stageRoot = () => null, state, ses
|
|
|
482
540
|
}
|
|
483
541
|
if (event?.waits !== "turn_end" || !event.command_id || !parseSteerLine(event.line)) return;
|
|
484
542
|
queued.set(event.command_id, event.line);
|
|
485
|
-
|
|
543
|
+
pendingChanged();
|
|
486
544
|
},
|
|
487
545
|
// A command ran (or was dropped): its line is no longer pending.
|
|
488
546
|
commandRan(event) {
|
|
489
|
-
if (event?.command_id && queued.delete(event.command_id))
|
|
547
|
+
if (event?.command_id && queued.delete(event.command_id)) pendingChanged();
|
|
490
548
|
},
|
|
491
549
|
// The steer lines pending (for the specs and the rail).
|
|
492
550
|
pending: () => pending(),
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../session"
|
|
4
|
+
require_relative "../session_access/local"
|
|
5
|
+
require_relative "../session_inbox"
|
|
6
|
+
require_relative "../reply_wait"
|
|
7
|
+
require_relative "../pending_question"
|
|
8
|
+
require_relative "../context_sources"
|
|
9
|
+
require_relative "../context_command"
|
|
10
|
+
require_relative "../send_command"
|
|
11
|
+
require_relative "../launch_mode"
|
|
12
|
+
require_relative "../model_profile"
|
|
13
|
+
|
|
14
|
+
module Samagotchi
|
|
15
|
+
module Web
|
|
16
|
+
# The routes chi's session commands need from another machine's chi web
|
|
17
|
+
# (chi --remote NAME …, SessionAccess::Remote), one per
|
|
18
|
+
# SessionAccess::Local operation that the page's own routes don't cover:
|
|
19
|
+
# a prefix resolved, the CLI's list rows, a wait's pre-send state and
|
|
20
|
+
# the wait itself, a note, a push into a context source. Each calls
|
|
21
|
+
# SessionAccess::Local, so a Remote maps 1:1 onto them. Included in
|
|
22
|
+
# App: its ROUTES name the handlers, and its helpers (json_response,
|
|
23
|
+
# parse_json, …) answer. See docs/architecture.md (chi web's routes for
|
|
24
|
+
# the session commands).
|
|
25
|
+
module SessionAccessRoutes
|
|
26
|
+
# A wait route answers within this many seconds (status timeout); the
|
|
27
|
+
# client asks again until its own --timeout.
|
|
28
|
+
WAIT_MAX_SECONDS = 30.0
|
|
29
|
+
# GET /api/info's feature: this chi web has these routes (a remote
|
|
30
|
+
# client checks it before its first session command).
|
|
31
|
+
FEATURE = "session_commands"
|
|
32
|
+
# GET /api/info's feature: this chi web has the routes a remote
|
|
33
|
+
# attached TUI needs (wake, worker; chi --remote NAME --attach).
|
|
34
|
+
ATTACH_FEATURE = "remote_attach"
|
|
35
|
+
# The longest POST …/wake waits for a worker's Bridge
|
|
36
|
+
# (TerminalUI::AttachLauncher::BRIDGE_WAIT, an attach's here).
|
|
37
|
+
WAKE_MAX_WAIT = 10.0
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# This server's sessions, as a session command reaches them here.
|
|
42
|
+
def session_access = @session_access ||= SessionAccess::Local.new(state_dir: @state_dir)
|
|
43
|
+
|
|
44
|
+
# A refusal an exception carries: its stable +code+, +detail+ (what
|
|
45
|
+
# the page shows; the message by default), and for a remote client
|
|
46
|
+
# the exception's own message and the fields its class is built from
|
|
47
|
+
# (session_id, reason, …), so it can raise the same class.
|
|
48
|
+
def refusal_response(status, code, error, detail: error.message, **fields)
|
|
49
|
+
json_response(status, { error: code, detail: detail, message: error.message, **fields }.compact)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The body as a Hash ({} when there is none), or nil when it isn't one.
|
|
53
|
+
def body_hash(req)
|
|
54
|
+
raw = request_body(req)
|
|
55
|
+
return {} if raw.to_s.strip.empty?
|
|
56
|
+
|
|
57
|
+
parsed = parse_json(raw)
|
|
58
|
+
parsed.is_a?(Hash) ? parsed : nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def truthy_param?(value) = %w[1 true yes].include?(value.to_s.strip.downcase)
|
|
62
|
+
|
|
63
|
+
# GET /api/sessions/resolve?id=PREFIX: the full id of the session an id
|
|
64
|
+
# or a unique prefix names (SessionAccess::Local#resolve). 200 {id};
|
|
65
|
+
# 404 not_found; 400 ambiguous_id with ids (the sessions it names).
|
|
66
|
+
def handle_resolve(req)
|
|
67
|
+
given = req.params["id"].to_s
|
|
68
|
+
return error_response(404, "not_found", "no session #{given[0, 80]}") unless Session.valid_id?(given)
|
|
69
|
+
|
|
70
|
+
json_response(200, { id: session_access.resolve(given) })
|
|
71
|
+
rescue Session::AmbiguousId => e
|
|
72
|
+
refusal_response(400, "ambiguous_id", e, ids: e.ids)
|
|
73
|
+
rescue ArgumentError
|
|
74
|
+
error_response(404, "not_found", "no session #{given[0, 80]}")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# GET /api/sessions/:id?brief=1: the session as a session command
|
|
78
|
+
# reads it (SessionAccess::Local#show): the file's status (not the
|
|
79
|
+
# displayed one), its names, folder, model and parent_answers, its
|
|
80
|
+
# owner's kind, and the file's pending question, never its messages.
|
|
81
|
+
# A chi web from before ?brief answers the whole read, which has the
|
|
82
|
+
# session part too.
|
|
83
|
+
def handle_brief_read(id)
|
|
84
|
+
session = @session_class.load(id, state_dir: default_state_dir)
|
|
85
|
+
owner = session_access.owner(id)
|
|
86
|
+
json_response(200, {
|
|
87
|
+
brief: true,
|
|
88
|
+
session: { id: session.id, status: session.status, title: session.title,
|
|
89
|
+
display_title: session.display_title, project_root: session.project_root,
|
|
90
|
+
working_directory: session.working_directory, model_name: session.model_name,
|
|
91
|
+
model_typed: session.model_typed, parent_answers: session.parent_answers,
|
|
92
|
+
owner: owner&.kind },
|
|
93
|
+
pending_question: session.pending_question&.to_file
|
|
94
|
+
})
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# GET /api/sessions/summaries: the rows chi sessions list prints
|
|
98
|
+
# (SessionManager.session_summaries: status, live, busy, owner, recap,
|
|
99
|
+
# ctx_pct, waiting, …), filtered as its flags filter. Params: dir (a
|
|
100
|
+
# folder: its project's sessions; none: every project's), cwd (in that
|
|
101
|
+
# folder or below), live=1, archived=1, tests=0 (leave test runs out),
|
|
102
|
+
# sort, order, limit. An array, newest first by default.
|
|
103
|
+
def handle_summaries(req)
|
|
104
|
+
dir, error = scope_dir(req.params["dir"])
|
|
105
|
+
return error if error
|
|
106
|
+
|
|
107
|
+
cwd = req.params["cwd"].to_s
|
|
108
|
+
cwd = nil if cwd.strip.empty?
|
|
109
|
+
return error_response(400, "invalid_cwd", "cwd is an absolute path: #{cwd[0, 200]}") if cwd && !cwd.start_with?("/")
|
|
110
|
+
|
|
111
|
+
rows = session_access.summaries(
|
|
112
|
+
live: truthy_param?(req.params["live"]), cwd: cwd, limit: sanitize_limit(req.params["limit"]),
|
|
113
|
+
include_tests: !%w[0 false no].include?(req.params["tests"].to_s.strip.downcase),
|
|
114
|
+
project_root: dir && ProjectScope.root_for(dir), include_archived: truthy_param?(req.params["archived"]),
|
|
115
|
+
sort: sanitize_sort(req.params["sort"]), order: sanitize_order(req.params["order"])
|
|
116
|
+
)
|
|
117
|
+
json_response(200, rows)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# POST /api/sessions/:id/wait/prepare: what chi send --wait and chi
|
|
121
|
+
# answer read before anything is sent (SessionAccess::Local#
|
|
122
|
+
# prepare_wait). Body (all optional): question_id (the question the
|
|
123
|
+
# baseline counts as reported; absent: the one pending now, null:
|
|
124
|
+
# none), turn (--turn: known_turn says whether it can be waited for),
|
|
125
|
+
# created (a session just created: no reply, no question), busy (chi
|
|
126
|
+
# send --wait with a message: refuse a running turn, 409 busy or
|
|
127
|
+
# waiting in SendCommand::Busy's words, with its fields: question,
|
|
128
|
+
# parent_strategy; queue: true, --queue, refuses only a waiting
|
|
129
|
+
# question). 200 {session_id, cursor, baseline:
|
|
130
|
+
# {messages, question_id, last_turn}, own_turn, known_turn, running,
|
|
131
|
+
# live, settled, last_turn_id}.
|
|
132
|
+
def handle_wait_prepare(req, id)
|
|
133
|
+
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
134
|
+
turn = body["turn"]
|
|
135
|
+
return error_response(400, "invalid_turn", "turn is a turn id") unless turn.nil? || turn.is_a?(String)
|
|
136
|
+
|
|
137
|
+
session = session_access.show(id)
|
|
138
|
+
if body["busy"] == true
|
|
139
|
+
owner = session_access.owner(id) if session.status == Session::STATUS_RUNNING
|
|
140
|
+
busy = SendCommand::Busy.for(session, owner: owner, queue: body["queue"] == true)
|
|
141
|
+
return json_response(409, busy_json(busy)) if busy
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
question_id = body.key?("question_id") ? body["question_id"]&.to_s : :pending
|
|
145
|
+
start = session_access.prepare_wait(id, session: session, question_id: question_id, turn: turn,
|
|
146
|
+
created: body["created"] == true)
|
|
147
|
+
json_response(200, wait_start_json(id, start))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# A Busy refusal: its words here, and its fields, which a chi --remote
|
|
151
|
+
# client words with its own commands (SessionAccess::Remote#busy).
|
|
152
|
+
def busy_json(busy)
|
|
153
|
+
{ error: busy.error, detail: busy.message, message: busy.message, session_id: busy.session_id,
|
|
154
|
+
question: busy.question&.to_file, parent_strategy: (strategy_json(busy.strategy) if busy.strategy) }.compact
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def strategy_json(strategy) = { name: strategy.name, allowed: strategy.allowed, origin: strategy.origin.to_s }
|
|
158
|
+
|
|
159
|
+
def wait_start_json(id, start)
|
|
160
|
+
{ session_id: id, cursor: start.cursor, baseline: start.baseline.to_h, own_turn: start.own_turn,
|
|
161
|
+
known_turn: start.known_turn, running: start.running, live: start.live, settled: start.settled,
|
|
162
|
+
last_turn_id: start.last_turn_id }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# POST /api/sessions/:id/wait: ReplyWait for at most WAIT_MAX_SECONDS
|
|
166
|
+
# (SessionAccess::Local#wait_reply). Body: cursor, baseline
|
|
167
|
+
# ({messages, question_id, last_turn}, as the prepare route gave it),
|
|
168
|
+
# turn, queued, ended_before, owner_grace (seconds, null: wait for
|
|
169
|
+
# whatever wakes a worker), timeout (seconds, capped). 200: the
|
|
170
|
+
# ReplyWait::Result's fields (status: done, waiting_for_answer,
|
|
171
|
+
# no_reply, error, stopped, timeout or worker_gone; text, file,
|
|
172
|
+
# question, outcome, …; never the Session) and parent_strategy, what
|
|
173
|
+
# a parent may pick on the session's questions here
|
|
174
|
+
# (ParentReport.strategy_for). A timeout status: ask again. 503
|
|
175
|
+
# server_stopping when chi web stops mid-wait.
|
|
176
|
+
def handle_wait(req, id)
|
|
177
|
+
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
178
|
+
options, error = wait_options(body)
|
|
179
|
+
return error if error
|
|
180
|
+
|
|
181
|
+
session_access.show(id)
|
|
182
|
+
result = session_access.wait_reply(id, **options, poll_interval: @wait_poll_interval,
|
|
183
|
+
cancelled: -> { !@server_running.call })
|
|
184
|
+
return error_response(503, "server_stopping", "chi web is stopping; wait again") if result.status == :canceled
|
|
185
|
+
|
|
186
|
+
json_response(200, wait_result_json(result, session_access.parent_strategy(id)))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The wait's ReplyWait arguments from the body: [options, nil] or
|
|
190
|
+
# [nil, a 400 response].
|
|
191
|
+
def wait_options(body)
|
|
192
|
+
strings = %w[cursor turn].to_h { |key| [key, body[key]] }
|
|
193
|
+
if strings.any? { |_, value| !value.nil? && !value.is_a?(String) }
|
|
194
|
+
return [nil, error_response(400, "invalid_wait", "cursor and turn are strings")]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
baseline, error = wait_baseline(body["baseline"])
|
|
198
|
+
return [nil, error] if error
|
|
199
|
+
|
|
200
|
+
numbers = %w[timeout owner_grace].to_h { |key| [key, body[key]] }
|
|
201
|
+
if numbers.any? { |_, value| !value.nil? && !(value.is_a?(Numeric) && value.positive?) }
|
|
202
|
+
return [nil, error_response(400, "invalid_wait", "timeout and owner_grace are positive seconds")]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
timeout = [numbers["timeout"] || WAIT_MAX_SECONDS, WAIT_MAX_SECONDS].min
|
|
206
|
+
[{ cursor: strings["cursor"], turn: strings["turn"], baseline: baseline, timeout: timeout,
|
|
207
|
+
owner_grace: numbers["owner_grace"], queued: body["queued"] == true, ended_before: body["ended_before"] == true }, nil]
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def wait_baseline(raw)
|
|
211
|
+
return [nil, nil] if raw.nil?
|
|
212
|
+
|
|
213
|
+
fields = { "messages" => Integer, "question_id" => String, "last_turn" => String }
|
|
214
|
+
unless raw.is_a?(Hash) && raw.all? { |key, value| fields.key?(key) && (value.nil? || value.is_a?(fields[key])) }
|
|
215
|
+
return [nil, error_response(400, "invalid_wait", "baseline is {messages, question_id, last_turn}")]
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
[ReplyWait::Baseline.new(**raw.transform_keys(&:to_sym)), nil]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
RESULT_FIELDS = %i[text file outcome limit error_kind retryable cancel_reason stopped_by kept_steps turn_id origin].freeze
|
|
222
|
+
|
|
223
|
+
def wait_result_json(result, strategy)
|
|
224
|
+
json = { status: result.status.to_s }
|
|
225
|
+
RESULT_FIELDS.each { |key| json[key] = result[key] }
|
|
226
|
+
json[:text] = json[:text]&.dup&.force_encoding(Encoding::UTF_8)&.scrub
|
|
227
|
+
json[:question] = result.question&.to_file
|
|
228
|
+
json[:parent_strategy] = strategy_json(strategy)
|
|
229
|
+
json
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# POST /api/sessions/:id/wake {wait}: the session's worker for a remote
|
|
233
|
+
# attach (SessionAccess::Local#wake): the live one, else resumed and
|
|
234
|
+
# waited for up to +wait+ seconds (WAKE_MAX_WAIT, also the most). 200
|
|
235
|
+
# {session_id, woke, worker: {epoch, started_at, version, features},
|
|
236
|
+
# model_name, default_model (this machine's config, for the TUI's
|
|
237
|
+
# status row), parent_strategy, installed (the newest chi here)}; 409
|
|
238
|
+
# owned_by_tui (a chi REPL has it), 409 scratch (a leftover scratch
|
|
239
|
+
# session, as chi --attach here says); 504 worker_timeout (its Bridge
|
|
240
|
+
# didn't come in time); 404 not_found.
|
|
241
|
+
def handle_wake(req, id)
|
|
242
|
+
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
243
|
+
wait = body.fetch("wait", WAKE_MAX_WAIT)
|
|
244
|
+
return error_response(400, "invalid_wait", "wait is positive seconds") unless wait.is_a?(Numeric) && wait.positive?
|
|
245
|
+
|
|
246
|
+
woken = session_access.wake(id, wait: [wait, WAKE_MAX_WAIT].min)
|
|
247
|
+
unless woken.sidecar
|
|
248
|
+
late = format(LaunchMode::BRIDGE_LATE, id)
|
|
249
|
+
return json_response(504, { error: "worker_timeout", detail: late, message: late, session_id: id })
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
json_response(200, { session_id: id, woke: woken.woke, worker: worker_json(woken.sidecar),
|
|
253
|
+
model_name: session_access.show(id).model_name,
|
|
254
|
+
default_model: ModelProfile.default_model_name,
|
|
255
|
+
parent_strategy: strategy_json(session_access.parent_strategy(id)),
|
|
256
|
+
installed: @hub&.installed })
|
|
257
|
+
rescue SessionManager::OwnedByTUI => e
|
|
258
|
+
owned_by_tui(e)
|
|
259
|
+
rescue SessionAccess::Refused => e
|
|
260
|
+
refusal_response(409, e.code, e, session_id: id)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# GET /api/sessions/:id/worker: the session's worker now, never
|
|
264
|
+
# started (SessionAccess::Local#worker): a remote attach's
|
|
265
|
+
# rediscovery and its features after a restart. 200 {session_id,
|
|
266
|
+
# live, stopped, owner: worker|tui|null, worker: {epoch, started_at,
|
|
267
|
+
# version, features}|null, parent_strategy}; 404 not_found.
|
|
268
|
+
def handle_worker(_req, id)
|
|
269
|
+
state = session_access.worker(id)
|
|
270
|
+
json_response(200, { session_id: id, live: state.live?, stopped: state.stopped, owner: state.owner&.kind,
|
|
271
|
+
worker: state.sidecar && worker_json(state.sidecar),
|
|
272
|
+
parent_strategy: strategy_json(session_access.parent_strategy(id)) })
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# A live worker as a remote client knows it: its epoch is its
|
|
276
|
+
# identity (the one in its event ids).
|
|
277
|
+
def worker_json(sidecar)
|
|
278
|
+
{ epoch: sidecar.epoch, started_at: sidecar.started_at, version: sidecar.version, features: sidecar.features }
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# POST /api/sessions/:id/notes {text, source}: a context note for the
|
|
282
|
+
# session (NoteDelivery, as chi note; source: where it came from,
|
|
283
|
+
# "cli" by default, never "broadcast"). 200 {status: "queued" (a
|
|
284
|
+
# worker adds it) or "waits" (the next start does), session_id,
|
|
285
|
+
# queued}; 409 owned_by_tui (status refused: a chi REPL has it); 400
|
|
286
|
+
# note_rejected (empty, or over 16 KiB).
|
|
287
|
+
def handle_notes(req, id)
|
|
288
|
+
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
289
|
+
text = body["text"]
|
|
290
|
+
source = body.fetch("source", "cli")
|
|
291
|
+
return error_response(400, "missing_fields", "text is required") unless text.is_a?(String)
|
|
292
|
+
return error_response(400, "invalid_source", "source is a word") unless source.is_a?(String) && !source.strip.empty?
|
|
293
|
+
return error_response(400, "invalid_source", "source broadcast is chi broadcast's own") if source.strip == "broadcast"
|
|
294
|
+
|
|
295
|
+
session_access.show(id)
|
|
296
|
+
result = session_access.note(id, text: text, source: source.strip)
|
|
297
|
+
if result.status == :refused
|
|
298
|
+
error = SessionManager::OwnedByTUI.new(id)
|
|
299
|
+
return refusal_response(409, "owned_by_tui", error, session_id: id, status: "refused", queued: 0)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
json_response(200, { status: result.status.to_s, session_id: id, queued: result.queued })
|
|
303
|
+
rescue SessionInbox::NoteRejected => e
|
|
304
|
+
error_response(400, "note_rejected", e.message)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# POST /api/sessions/:id/context/:name/push {text, summary, wake,
|
|
308
|
+
# hint}: new text for the session's --push source (chi context push;
|
|
309
|
+
# the fields as a source's JSON output gives them, checked the same
|
|
310
|
+
# way). 200 {status: "pushed", name, before, revision, changed}; 404
|
|
311
|
+
# not_found (no such source); 409 not_push (a command source); 400
|
|
312
|
+
# invalid_text.
|
|
313
|
+
def handle_context_push(req, id, name)
|
|
314
|
+
body = body_hash(req) or return error_response(400, "invalid_json", "invalid JSON body")
|
|
315
|
+
return error_response(400, "missing_fields", "text is required") unless body["text"].is_a?(String)
|
|
316
|
+
|
|
317
|
+
fetched = begin
|
|
318
|
+
ContextSources.parse_output(JSON.generate(body.slice("text", "summary", "wake", "hint")))
|
|
319
|
+
rescue ContextSources::Invalid => e
|
|
320
|
+
return error_response(400, "invalid_text", e.message)
|
|
321
|
+
end
|
|
322
|
+
pushed = session_access.context_push(context_target(id), name, fetched)
|
|
323
|
+
case pushed.status
|
|
324
|
+
when :missing then error_response(404, "not_found", "no source #{name[0, 40]}")
|
|
325
|
+
when :not_push then error_response(409, "not_push", SessionAccess::ContextPush.not_push_message(name))
|
|
326
|
+
else json_response(200, { status: "pushed", name: name, before: pushed.before, revision: pushed.revision,
|
|
327
|
+
changed: pushed.before != pushed.revision })
|
|
328
|
+
end
|
|
329
|
+
rescue ContextSources::Invalid
|
|
330
|
+
error_response(404, "not_found", "no source #{name[0, 40]}")
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# The session as chi context's Target (SessionAccess::Local's context
|
|
334
|
+
# calls take one).
|
|
335
|
+
# @raise [ArgumentError] no such session
|
|
336
|
+
def context_target(id)
|
|
337
|
+
session = session_access.show(id)
|
|
338
|
+
ContextCommand::Target.new(session_id: id, project_root: session.project_root, cwd: session.working_directory,
|
|
339
|
+
label: id[0, 8])
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
end
|
|
@@ -10,10 +10,14 @@ module Samagotchi
|
|
|
10
10
|
# Bridge (port, bind), when it started (a new worker behind the same
|
|
11
11
|
# "worker" owner), the chi it runs (chi update, the web's stale-worker
|
|
12
12
|
# badge), the input-file format it reads (SessionInbox::INPUT_FORMAT) and
|
|
13
|
-
# what else it can do (Bridge::FEATURES)
|
|
13
|
+
# what else it can do (Bridge::FEATURES), and its Bridge's epoch (the
|
|
14
|
+
# one worker identity: the epoch in every event id, so a remote client
|
|
15
|
+
# tells a restarted worker apart; nil from a worker before it). The
|
|
16
|
+
# Bridge writes it on start
|
|
14
17
|
# and removes it on stop; one a dead worker left stays until a client's
|
|
15
18
|
# probe finds its port closed (.live with unlink: true).
|
|
16
|
-
class WorkerSidecar < Data.define(:port, :bind, :session_id, :started_at, :version, :input_format, :features
|
|
19
|
+
class WorkerSidecar < Data.define(:port, :bind, :session_id, :started_at, :version, :input_format, :features,
|
|
20
|
+
:epoch)
|
|
17
21
|
FILE = "bridge.json"
|
|
18
22
|
# Seconds a liveness probe waits for the port to take a connect.
|
|
19
23
|
PROBE_TIMEOUT = 0.2
|
|
@@ -29,7 +33,7 @@ module Samagotchi
|
|
|
29
33
|
port = data["port"]
|
|
30
34
|
new(port: port.respond_to?(:to_i) ? port.to_i : 0, bind: data["bind"], session_id: data["session_id"],
|
|
31
35
|
started_at: data["started_at"], version: data["version"], input_format: data["input_format"],
|
|
32
|
-
features: Array(data["features"]).map(&:to_s))
|
|
36
|
+
features: Array(data["features"]).map(&:to_s), epoch: data["epoch"]&.to_s)
|
|
33
37
|
rescue JSON::ParserError, SystemCallError
|
|
34
38
|
nil
|
|
35
39
|
end
|
|
@@ -63,13 +67,16 @@ module Samagotchi
|
|
|
63
67
|
# @return [Integer, nil] .live's port
|
|
64
68
|
def self.live_port(session_dir, unlink:, host: "127.0.0.1") = live(session_dir, unlink: unlink, host: host)&.port
|
|
65
69
|
|
|
66
|
-
def initialize(port:, bind: nil, session_id: nil, started_at: nil, version: nil, input_format: nil, features: []
|
|
70
|
+
def initialize(port:, bind: nil, session_id: nil, started_at: nil, version: nil, input_format: nil, features: [],
|
|
71
|
+
epoch: nil) = super
|
|
67
72
|
|
|
68
|
-
# Written whole or not at all; input_format only when
|
|
73
|
+
# Written whole or not at all; input_format and epoch only when they
|
|
74
|
+
# name one.
|
|
69
75
|
def write(session_dir)
|
|
70
76
|
FileUtils.mkdir_p(session_dir)
|
|
71
77
|
record = to_h.transform_keys(&:to_s)
|
|
72
78
|
record.delete("input_format") if input_format.nil?
|
|
79
|
+
record.delete("epoch") if epoch.nil?
|
|
73
80
|
AtomicFile.write(self.class.path(session_dir), "#{JSON.pretty_generate(record)}\n")
|
|
74
81
|
end
|
|
75
82
|
end
|