pi-straw-boss 0.59.0 → 0.59.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/main.mjs +137 -20
- package/package.json +1 -1
- package/src/link/dialogs-out.ts +141 -19
- package/src/link/holder.ts +1 -0
- package/src/server/dispatcher.ts +16 -1
- package/src/server/guihost.ts +1 -1
- package/src/server/main.ts +3 -3
package/dist/server/main.mjs
CHANGED
|
@@ -8036,8 +8036,8 @@ function createGuiHost(o) {
|
|
|
8036
8036
|
dialogs: () => ({
|
|
8037
8037
|
hub: theHub(),
|
|
8038
8038
|
read: async () => void await (forwardNet ??= netSource({ ...o.tower ? { services: (workspace) => o.tower.view(workspace) } : {}, run: cfg.run, world: cfg.world, sessions, hub: theHub() })).read(),
|
|
8039
|
-
/** The
|
|
8040
|
-
|
|
8039
|
+
/** The unit the hub files dialogs under `key` (known after a `read`): its pane for the dispatcher, its name and role for the label a controller is told. */
|
|
8040
|
+
lookup: (key2) => forwardNet?.lookup(key2)
|
|
8041
8041
|
}),
|
|
8042
8042
|
/** At the server's start: the GUI on the recorded ports unless it was stopped on purpose (which only the boss's own command undoes). */
|
|
8043
8043
|
async start() {
|
|
@@ -8446,6 +8446,9 @@ var MAX_SEEN = 1024;
|
|
|
8446
8446
|
var UNANSWERED_MS = 30 * 6e4;
|
|
8447
8447
|
var REFRESH_MS = 10 * 6e4;
|
|
8448
8448
|
var UNKNOWN_RECHECK_MS = 15e3;
|
|
8449
|
+
var PASS_STUCK_MS = 6e4;
|
|
8450
|
+
var CLOSE_TRIES = 5;
|
|
8451
|
+
var MAX_CLOSES = 256;
|
|
8449
8452
|
var DialogForwarder = class {
|
|
8450
8453
|
d;
|
|
8451
8454
|
open = /* @__PURE__ */ new Map();
|
|
@@ -8453,12 +8456,29 @@ var DialogForwarder = class {
|
|
|
8453
8456
|
seen = /* @__PURE__ */ new Set();
|
|
8454
8457
|
off;
|
|
8455
8458
|
polling;
|
|
8459
|
+
/** The closes not posted yet, per controller and dialog, drained by every pass until each is posted. */
|
|
8460
|
+
closes = /* @__PURE__ */ new Map();
|
|
8461
|
+
/** The passes let go as stuck that have not returned; at most one is outstanding, so a call that never returns costs one pending pass and not one a minute. */
|
|
8462
|
+
released = /* @__PURE__ */ new Set();
|
|
8463
|
+
/** The line that a second stuck pass is not let go was written for this stretch. */
|
|
8464
|
+
hung = false;
|
|
8456
8465
|
again = false;
|
|
8466
|
+
/** The pass that counts: a pass let go as stuck has an older number and stops at its next turn. */
|
|
8467
|
+
gen = 0;
|
|
8468
|
+
/** When the pass in flight began, and the call it waits at (`dialog-pass-stuck` names it). */
|
|
8469
|
+
passAt = 0;
|
|
8470
|
+
step = "idle";
|
|
8471
|
+
/** What the last `dialog-controllers` line said, so a line is written when it changes and not at every pass. */
|
|
8472
|
+
counted;
|
|
8473
|
+
/** The room for open questions was used up, and the line that says so was written. */
|
|
8474
|
+
full = false;
|
|
8457
8475
|
constructor(d) {
|
|
8458
8476
|
this.d = d;
|
|
8459
8477
|
}
|
|
8460
8478
|
start() {
|
|
8461
|
-
this.off
|
|
8479
|
+
if (this.off) return;
|
|
8480
|
+
this.off = this.d.hub.onChange(() => void this.poll());
|
|
8481
|
+
this.d.log({ event: "dialog-forwarder", state: "started" });
|
|
8462
8482
|
}
|
|
8463
8483
|
stop() {
|
|
8464
8484
|
this.off?.();
|
|
@@ -8468,19 +8488,37 @@ var DialogForwarder = class {
|
|
|
8468
8488
|
tick() {
|
|
8469
8489
|
return this.poll();
|
|
8470
8490
|
}
|
|
8471
|
-
/** Looks at the hub now; calls that overlap run once more after the one in flight. */
|
|
8491
|
+
/** Looks at the hub now; calls that overlap run once more after the one in flight, and a pass in flight for `PASS_STUCK_MS` is let go (its next look is a new pass). */
|
|
8472
8492
|
poll() {
|
|
8473
|
-
if (this.polling)
|
|
8493
|
+
if (this.polling) {
|
|
8494
|
+
if (this.now() - this.passAt < PASS_STUCK_MS) return this.again = true, this.polling;
|
|
8495
|
+
if (this.released.size) {
|
|
8496
|
+
if (!this.hung) this.d.log({ event: "dialog-pass-hung", step: this.step, ms: this.now() - this.passAt });
|
|
8497
|
+
this.hung = true;
|
|
8498
|
+
return this.again = true, this.polling;
|
|
8499
|
+
}
|
|
8500
|
+
this.d.log({ event: "dialog-pass-stuck", step: this.step, ms: this.now() - this.passAt });
|
|
8501
|
+
this.gen++;
|
|
8502
|
+
this.released.add(this.polling);
|
|
8503
|
+
this.polling = void 0;
|
|
8504
|
+
for (const d of this.open.values()) d.sent.clear();
|
|
8505
|
+
}
|
|
8506
|
+
const gen = this.gen;
|
|
8474
8507
|
const run3 = (async () => {
|
|
8475
8508
|
do {
|
|
8476
8509
|
this.again = false;
|
|
8510
|
+
this.passAt = this.now();
|
|
8477
8511
|
try {
|
|
8478
|
-
await this.pass();
|
|
8512
|
+
await this.pass(gen);
|
|
8479
8513
|
} catch (error) {
|
|
8480
|
-
this.d.log({ event: "dialog-forward-failed", error: String(error.message).slice(0, 200) });
|
|
8514
|
+
this.d.log({ event: "dialog-forward-failed", step: this.step, error: String(error.message).slice(0, 200) });
|
|
8481
8515
|
}
|
|
8482
|
-
} while (this.again);
|
|
8483
|
-
})().finally(() =>
|
|
8516
|
+
} while (this.again && gen === this.gen);
|
|
8517
|
+
})().finally(() => {
|
|
8518
|
+
if (this.polling === run3) this.polling = void 0;
|
|
8519
|
+
this.released.delete(run3);
|
|
8520
|
+
if (!this.released.size) this.hung = false;
|
|
8521
|
+
});
|
|
8484
8522
|
return this.polling = run3;
|
|
8485
8523
|
}
|
|
8486
8524
|
/** Whose unit asked: looked up until it is known, then kept. A lookup that fails leaves it unknown, which is the admins' alone. */
|
|
@@ -8492,10 +8530,27 @@ var DialogForwarder = class {
|
|
|
8492
8530
|
scopeOf(d) {
|
|
8493
8531
|
return this.d.dispatcher ? d.who ? { device: d.who.device, ...d.who.team !== void 0 ? { team: d.who.team } : {} } : {} : EVERYONE;
|
|
8494
8532
|
}
|
|
8495
|
-
|
|
8533
|
+
/** Records where the pass in flight waits (`dialog-pass-stuck` names it); a pass let go as stuck no longer writes it. */
|
|
8534
|
+
at(gen, step) {
|
|
8535
|
+
if (gen === this.gen) this.step = step;
|
|
8536
|
+
}
|
|
8537
|
+
/** One line when the controllers the forwarder sees change in number or in whether the list is complete, so a box with nobody to tell says so once (and the first pass says how it starts). */
|
|
8538
|
+
count(controllers, known2) {
|
|
8539
|
+
const line2 = `${controllers}/${known2}`;
|
|
8540
|
+
if (line2 === this.counted) return;
|
|
8541
|
+
this.counted = line2;
|
|
8542
|
+
this.d.log({ event: "dialog-controllers", count: controllers, known: known2, open: this.open.size });
|
|
8543
|
+
}
|
|
8544
|
+
async pass(gen) {
|
|
8496
8545
|
const to = this.d.controllers();
|
|
8546
|
+
this.count(to.length, this.d.controllersKnown?.() ?? true);
|
|
8547
|
+
if (this.closes.size && !await this.drain(gen)) return;
|
|
8497
8548
|
if (!to.length && !this.open.size) return;
|
|
8498
|
-
if (to.length)
|
|
8549
|
+
if (to.length) {
|
|
8550
|
+
this.at(gen, "read");
|
|
8551
|
+
await this.d.read();
|
|
8552
|
+
if (gen !== this.gen) return;
|
|
8553
|
+
}
|
|
8499
8554
|
for (const o of to.length ? this.d.hub.answerable() : []) {
|
|
8500
8555
|
const id = o.target.answer.dialog;
|
|
8501
8556
|
if (this.open.has(id) || this.skipped.has(id)) continue;
|
|
@@ -8504,19 +8559,32 @@ var DialogForwarder = class {
|
|
|
8504
8559
|
this.skipped.add(id);
|
|
8505
8560
|
this.d.log({ event: "dialog-oversize", dialog: id, bytes: Buffer.byteLength(text5) });
|
|
8506
8561
|
const unit = { key: o.key };
|
|
8562
|
+
this.at(gen, "dispatcher");
|
|
8507
8563
|
await this.whose(unit);
|
|
8508
|
-
|
|
8564
|
+
this.at(gen, "notice");
|
|
8565
|
+
await this.d.notice?.(`oversize/${id}`, { kind: "dialog-oversize", unit: o.key, text: `A question of ${this.labelOf(o.key, o.owner)} was not forwarded: it is ${Buffer.byteLength(text5)} bytes, over the ${LIMITS2.dialog} the link carries. It waits at the device.` }, this.scopeOf(unit));
|
|
8566
|
+
if (gen !== this.gen) return;
|
|
8567
|
+
continue;
|
|
8568
|
+
}
|
|
8569
|
+
if (this.open.size >= MAX_OPEN2) {
|
|
8570
|
+
if (!this.full) this.d.log({ event: "dialog-full", open: this.open.size });
|
|
8571
|
+
this.full = true;
|
|
8509
8572
|
continue;
|
|
8510
8573
|
}
|
|
8511
|
-
|
|
8512
|
-
this.
|
|
8574
|
+
this.open.set(id, { key: o.key, target: o.target, owner: o.owner, label: this.labelOf(o.key, o.owner), text: text5, sent: /* @__PURE__ */ new Map(), away: /* @__PURE__ */ new Set(), claimed: false, since: this.now(), told: false });
|
|
8575
|
+
this.d.log({ event: "dialog-seen", dialog: id, unit: o.key, owner: o.owner });
|
|
8513
8576
|
}
|
|
8514
8577
|
for (const [id, d] of [...this.open]) {
|
|
8578
|
+
this.at(gen, "dispatcher");
|
|
8515
8579
|
await this.whose(d);
|
|
8580
|
+
if (gen !== this.gen) return;
|
|
8516
8581
|
const state = this.d.hub.outcome(id);
|
|
8517
8582
|
if (!state.open) {
|
|
8518
8583
|
this.open.delete(id);
|
|
8519
|
-
|
|
8584
|
+
this.full = false;
|
|
8585
|
+
this.d.log({ event: "dialog-ended", dialog: id, by: state.by, sent: d.sent.size });
|
|
8586
|
+
for (const k of d.sent.keys()) this.hold(k, id, state.by);
|
|
8587
|
+
if (!await this.drain(gen)) return;
|
|
8520
8588
|
continue;
|
|
8521
8589
|
}
|
|
8522
8590
|
if (d.claimed && d.unknownAt !== void 0 && this.now() - d.unknownAt >= UNKNOWN_RECHECK_MS) {
|
|
@@ -8529,10 +8597,13 @@ var DialogForwarder = class {
|
|
|
8529
8597
|
if (mine.some((c) => `${c.team}
|
|
8530
8598
|
${c.device}` === k)) continue;
|
|
8531
8599
|
d.sent.delete(k);
|
|
8532
|
-
|
|
8600
|
+
this.hold(k, id, "withdrawn");
|
|
8601
|
+
if (!await this.drain(gen)) return;
|
|
8533
8602
|
}
|
|
8534
8603
|
if (!d.told && !d.claimed && to.length && this.now() - d.since >= UNANSWERED_MS) {
|
|
8535
|
-
|
|
8604
|
+
this.at(gen, "notice");
|
|
8605
|
+
const sent = await this.d.notice?.(`unanswered/${id}`, { kind: "dialog-unanswered", unit: d.key, text: `A question of ${d.label} has waited ${Math.round((this.now() - d.since) / 6e4)} minutes for an answer.` }, this.scopeOf(d));
|
|
8606
|
+
if (gen !== this.gen) return;
|
|
8536
8607
|
d.told = sent !== false;
|
|
8537
8608
|
}
|
|
8538
8609
|
if (!mine.length && !d.unrouted) {
|
|
@@ -8548,16 +8619,53 @@ ${c.device}`;
|
|
|
8548
8619
|
if (at !== void 0) continue;
|
|
8549
8620
|
} else if (at !== void 0 && !d.away.delete(k) && this.now() - at < REFRESH_MS) continue;
|
|
8550
8621
|
d.sent.set(k, this.now());
|
|
8551
|
-
|
|
8622
|
+
this.at(gen, "post");
|
|
8623
|
+
const posted = await this.send(k, "dialog-open", { dialogId: id, digest: d.target.answer.digest, owner: d.label, dialog: d.text });
|
|
8624
|
+
if (gen !== this.gen) return;
|
|
8625
|
+
if (!posted) d.sent.delete(k);
|
|
8552
8626
|
}
|
|
8553
8627
|
}
|
|
8554
8628
|
for (const id of [...this.skipped]) if (!this.d.hub.outcome(id).open) this.skipped.delete(id);
|
|
8555
8629
|
}
|
|
8630
|
+
/** Keeps a close to post: the oldest is given up when the room is used up. */
|
|
8631
|
+
hold(k, dialogId, by) {
|
|
8632
|
+
this.closes.set(`${k}
|
|
8633
|
+
${dialogId}`, { k, dialogId, by, at: this.now(), tries: 0 });
|
|
8634
|
+
for (const [key2, c] of this.closes) {
|
|
8635
|
+
if (this.closes.size <= MAX_CLOSES) break;
|
|
8636
|
+
this.closes.delete(key2);
|
|
8637
|
+
this.d.log({ event: "dialog-close-dropped", dialog: c.dialogId, reason: "full" });
|
|
8638
|
+
}
|
|
8639
|
+
}
|
|
8640
|
+
/** Posts the held closes, each removed once posted. One that failed `CLOSE_TRIES` times, or whose card has expired at the controller (`OPEN_STALE_MS`), is given up with a line. False when this pass was let go meanwhile. */
|
|
8641
|
+
async drain(gen) {
|
|
8642
|
+
for (const [key2, c] of [...this.closes]) {
|
|
8643
|
+
if (this.now() - c.at > OPEN_STALE_MS) {
|
|
8644
|
+
this.closes.delete(key2);
|
|
8645
|
+
this.d.log({ event: "dialog-close-dropped", dialog: c.dialogId, reason: "stale" });
|
|
8646
|
+
continue;
|
|
8647
|
+
}
|
|
8648
|
+
this.at(gen, "post");
|
|
8649
|
+
const posted = await this.send(c.k, "dialog-close", { dialogId: c.dialogId, by: c.by });
|
|
8650
|
+
if (posted) this.closes.delete(key2);
|
|
8651
|
+
else if (++c.tries >= CLOSE_TRIES) {
|
|
8652
|
+
this.closes.delete(key2);
|
|
8653
|
+
this.d.log({ event: "dialog-close-dropped", dialog: c.dialogId, reason: "failed" });
|
|
8654
|
+
}
|
|
8655
|
+
if (gen !== this.gen) return false;
|
|
8656
|
+
}
|
|
8657
|
+
return true;
|
|
8658
|
+
}
|
|
8556
8659
|
now = () => this.d.now?.() ?? Date.now();
|
|
8660
|
+
/** What the controllers are told asks: the unit's name when it is known, else the owner of the dialog. */
|
|
8661
|
+
labelOf(key2, owner) {
|
|
8662
|
+
return this.d.unit?.(key2) || owner;
|
|
8663
|
+
}
|
|
8557
8664
|
async send(k, kind, payload) {
|
|
8558
8665
|
const [team, device] = k.split("\n");
|
|
8559
8666
|
try {
|
|
8560
8667
|
await this.d.post(team, device, kind, payload);
|
|
8668
|
+
this.d.log({ event: "dialog-sent", kind, dialog: payload.dialogId, team, to: device });
|
|
8561
8669
|
return true;
|
|
8562
8670
|
} catch (error) {
|
|
8563
8671
|
this.d.log({ event: "dialog-post-failed", kind, dialog: payload.dialogId, error: String(error.message).slice(0, 200) });
|
|
@@ -8664,6 +8772,13 @@ function dispatcherLookup(d) {
|
|
|
8664
8772
|
return panes3 ? dispatcherOf(pane, panes3, d.allyPanes()) : void 0;
|
|
8665
8773
|
};
|
|
8666
8774
|
}
|
|
8775
|
+
var KIND = { main: "main", member: "member", subagent: "subagent", ally: "ally run", run: "run" };
|
|
8776
|
+
function unitLabel(found) {
|
|
8777
|
+
const name2 = allyLabel(found?.name);
|
|
8778
|
+
if (!found || !/[\p{L}\p{N}]/u.test(name2)) return void 0;
|
|
8779
|
+
const kind = KIND[found.role];
|
|
8780
|
+
return kind ? `${kind} - ${name2}` : name2;
|
|
8781
|
+
}
|
|
8667
8782
|
|
|
8668
8783
|
// src/server/opentower.ts
|
|
8669
8784
|
import { realpathSync as realpathSync7 } from "node:fs";
|
|
@@ -11353,6 +11468,7 @@ ${Buffer5.from(header2.id).toString("hex")}`).digest("hex").slice(0, 16);
|
|
|
11353
11468
|
const sender = roster.members.get(header2.from);
|
|
11354
11469
|
if (!(this.o.attended?.() ?? true) || !sender || sender.attended) return void this.o.store.log({ event: "drop", team: run3.team, reason: "dialog-open-unexpected", from: header2.from, kind: "dialog-open" });
|
|
11355
11470
|
const fresh = this.dialogViews.put({ team: run3.team, device: header2.from, dialogId: p.dialogId, digest: p.digest, owner: p.owner, dialog: p.dialog, at: this.now() });
|
|
11471
|
+
this.o.store.log({ event: "dialog-in", team: run3.team, from: header2.from, dialog: p.dialogId, fresh });
|
|
11356
11472
|
if (fresh) this.o.notify(`Straw Link: ${sender.label} asks a question (${restrictLabel(p.owner)})`, "warning");
|
|
11357
11473
|
}
|
|
11358
11474
|
takeDialogClose(header2, p) {
|
|
@@ -18847,11 +18963,12 @@ async function runServer(o) {
|
|
|
18847
18963
|
const dialogsOut = () => forwarder ??= new DialogForwarder({
|
|
18848
18964
|
hub: guiHost.dialogs().hub,
|
|
18849
18965
|
read: guiHost.dialogs().read,
|
|
18850
|
-
dispatcher: dispatcherLookup({ paneOf: (key2) => guiHost.dialogs().
|
|
18966
|
+
dispatcher: dispatcherLookup({ paneOf: (key2) => guiHost.dialogs().lookup(key2)?.pane, list: () => panes(cfg.run), allyPanes: () => towers?.runs.allyPanes() ?? [], now }),
|
|
18851
18967
|
admin: (team, device) => link?.holder?.isAdmin(team, device) ?? false,
|
|
18852
18968
|
controllers: () => link?.holder?.dialogControllers() ?? [],
|
|
18853
18969
|
online: (team, device) => link?.holder?.deviceOnline(team, device) ?? false,
|
|
18854
18970
|
controllersKnown: () => link?.holder?.controllersKnown() ?? false,
|
|
18971
|
+
unit: (key2) => unitLabel(guiHost.dialogs().lookup(key2)),
|
|
18855
18972
|
post: async (team, device, kind, payload) => {
|
|
18856
18973
|
if (!link?.holder) throw new Error("The link holder is not running");
|
|
18857
18974
|
await link.holder.sendDialog(team, device, kind, payload);
|
|
@@ -18971,7 +19088,7 @@ ${unit}`), note = ws ? readNotes(slot, ws).at(-1) : void 0, sent = ws ? link?.ho
|
|
|
18971
19088
|
if (slot.primary && !leaving && phase === "starting") {
|
|
18972
19089
|
dialogsOut().start();
|
|
18973
19090
|
timers2.push(setInterval(() => void Promise.all([dialogsOut().tick(), unitsOut().tick()]), o.unitsMs ?? 15e3));
|
|
18974
|
-
}
|
|
19091
|
+
} else log.line("dialog-forwarder", { state: "skipped", primary: slot.primary, leaving: Boolean(leaving), phase });
|
|
18975
19092
|
if (phase === "starting") {
|
|
18976
19093
|
phase = "ready";
|
|
18977
19094
|
const stale = own.lastError !== void 0 && own.lastError === prior?.lastError;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-straw-boss",
|
|
3
|
-
"version": "0.59.
|
|
3
|
+
"version": "0.59.1",
|
|
4
4
|
"description": "Straw Boss coordination for pi in Herdr: sessions join a tab's net by choice, the first joiner is its main agent, a workspace tower opens on demand with a live monitoring dashboard, and boss_broadcast reaches the rest",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/link/dialogs-out.ts
CHANGED
|
@@ -19,7 +19,8 @@ export interface RemoteDialog { select?: unknown; questions?: unknown; cancel?:
|
|
|
19
19
|
export type RemoteAnswer = { choice: number } | { answers: unknown[] } | { cancel: true };
|
|
20
20
|
|
|
21
21
|
/** The most dialogs one device keeps open for forwarding, and the answer ids it remembers. */
|
|
22
|
-
const MAX_OPEN = 32
|
|
22
|
+
export const MAX_OPEN = 32;
|
|
23
|
+
const MAX_SEEN = 1024;
|
|
23
24
|
|
|
24
25
|
export interface DialogsOutDeps {
|
|
25
26
|
hub: Pick<Hub, "onChange" | "answerable" | "outcome" | "send">;
|
|
@@ -44,6 +45,11 @@ export interface DialogsOutDeps {
|
|
|
44
45
|
dispatcher?(key: string): Promise<{ device: string; team?: string } | undefined>;
|
|
45
46
|
/** Whether `device` is an admin of `team`'s roster now: an admin is a recipient of every question. Absent: nobody is. */
|
|
46
47
|
admin?(team: string, device: string): boolean;
|
|
48
|
+
/**
|
|
49
|
+
* What a controller is told asks the question in the unit `key`: the unit's name, scrubbed for leaving the box (known after a `read`). Nothing: the dialog's owner, the package that opened it (`rpiv`), is told instead, which names
|
|
50
|
+
* no unit. Absent: the owner.
|
|
51
|
+
*/
|
|
52
|
+
unit?(key: string): string | undefined;
|
|
47
53
|
}
|
|
48
54
|
/** A dialog nobody has answered this long is told to the controllers, once. */
|
|
49
55
|
export const UNANSWERED_MS = 30 * 60_000;
|
|
@@ -51,9 +57,17 @@ export const UNANSWERED_MS = 30 * 60_000;
|
|
|
51
57
|
export const REFRESH_MS = 10 * 60_000;
|
|
52
58
|
/** How long an answer that no session confirmed (`unknown`) is trusted to have landed before a dialog that is still open is taken as unanswered. */
|
|
53
59
|
export const UNKNOWN_RECHECK_MS = 15_000;
|
|
60
|
+
/** A pass that has not finished this long is stuck: the next look logs where it waits (`dialog-pass-stuck`), lets it go, and starts a pass of its own, so one call that never returns cannot end forwarding for good. */
|
|
61
|
+
export const PASS_STUCK_MS = 60_000;
|
|
62
|
+
/** A close that failed to post is tried at this many passes, then given up (the card expires at the controller anyway). */
|
|
63
|
+
export const CLOSE_TRIES = 5;
|
|
64
|
+
/** The most closes held to be posted; the oldest is given up when more come. */
|
|
65
|
+
const MAX_CLOSES = 256;
|
|
66
|
+
/** A `dialog-close` (an end, or a withdrawal) not yet posted to a controller: it is kept apart from the open dialog, which is gone from `open` once it ends, so a pass let go as stuck leaves it for the next to post. */
|
|
67
|
+
interface PendingClose { k: string; dialogId: string; by: string; at: number; tries: number }
|
|
54
68
|
export interface DialogAnswerInput { team: string; from: string; dialogId: string; digest: string; answer: string; answerId: string }
|
|
55
69
|
/** `sent` is where the dialog was last sent, per controller, and when; `claimed` is set while an answer is on its way or has been accepted; `unknownAt` is when an answer no session confirmed was sent. */
|
|
56
|
-
interface Open { key: string; target: Parameters<Hub["send"]>[1]; owner: string; text: string; sent: Map<string, number>; away: Set<string>; claimed: boolean; unknownAt?: number; since: number; told: boolean; /** The log line that nobody can be told of this question was written. */ unrouted?: boolean; /** Whose unit asked, once it is known (`dispatcher`). */ who?: { device: string; team?: string } }
|
|
70
|
+
interface Open { key: string; target: Parameters<Hub["send"]>[1]; /** The package that owns the dialog (`rpiv`). */ owner: string; /** What the controllers are told asks: the unit's name when the box can say it, else `owner`. */ label: string; text: string; sent: Map<string, number>; away: Set<string>; claimed: boolean; unknownAt?: number; since: number; told: boolean; /** The log line that nobody can be told of this question was written. */ unrouted?: boolean; /** Whose unit asked, once it is known (`dispatcher`). */ who?: { device: string; team?: string } }
|
|
57
71
|
|
|
58
72
|
export class DialogForwarder {
|
|
59
73
|
private readonly d: DialogsOutDeps;
|
|
@@ -62,12 +76,29 @@ export class DialogForwarder {
|
|
|
62
76
|
private seen = new Set<string>();
|
|
63
77
|
private off: (() => void) | undefined;
|
|
64
78
|
private polling: Promise<void> | undefined;
|
|
79
|
+
/** The closes not posted yet, per controller and dialog, drained by every pass until each is posted. */
|
|
80
|
+
private closes = new Map<string, PendingClose>();
|
|
81
|
+
/** The passes let go as stuck that have not returned; at most one is outstanding, so a call that never returns costs one pending pass and not one a minute. */
|
|
82
|
+
private released = new Set<Promise<void>>();
|
|
83
|
+
/** The line that a second stuck pass is not let go was written for this stretch. */
|
|
84
|
+
private hung = false;
|
|
65
85
|
private again = false;
|
|
86
|
+
/** The pass that counts: a pass let go as stuck has an older number and stops at its next turn. */
|
|
87
|
+
private gen = 0;
|
|
88
|
+
/** When the pass in flight began, and the call it waits at (`dialog-pass-stuck` names it). */
|
|
89
|
+
private passAt = 0;
|
|
90
|
+
private step = "idle";
|
|
91
|
+
/** What the last `dialog-controllers` line said, so a line is written when it changes and not at every pass. */
|
|
92
|
+
private counted: string | undefined;
|
|
93
|
+
/** The room for open questions was used up, and the line that says so was written. */
|
|
94
|
+
private full = false;
|
|
66
95
|
constructor(d: DialogsOutDeps) {
|
|
67
96
|
this.d = d;
|
|
68
97
|
}
|
|
69
98
|
start(): void {
|
|
70
|
-
this.off
|
|
99
|
+
if (this.off) return;
|
|
100
|
+
this.off = this.d.hub.onChange(() => void this.poll());
|
|
101
|
+
this.d.log({ event: "dialog-forwarder", state: "started" });
|
|
71
102
|
}
|
|
72
103
|
stop(): void {
|
|
73
104
|
this.off?.();
|
|
@@ -77,19 +108,39 @@ export class DialogForwarder {
|
|
|
77
108
|
tick(): Promise<void> {
|
|
78
109
|
return this.poll();
|
|
79
110
|
}
|
|
80
|
-
/** Looks at the hub now; calls that overlap run once more after the one in flight. */
|
|
111
|
+
/** Looks at the hub now; calls that overlap run once more after the one in flight, and a pass in flight for `PASS_STUCK_MS` is let go (its next look is a new pass). */
|
|
81
112
|
poll(): Promise<void> {
|
|
82
|
-
if (this.polling)
|
|
83
|
-
|
|
113
|
+
if (this.polling) {
|
|
114
|
+
if (this.now() - this.passAt < PASS_STUCK_MS) return ((this.again = true), this.polling);
|
|
115
|
+
if (this.released.size) {
|
|
116
|
+
// A pass let go has not returned: its call may never, and another release would leave one more pending pass behind. The pass in flight is waited on until that one ends.
|
|
117
|
+
if (!this.hung) this.d.log({ event: "dialog-pass-hung", step: this.step, ms: this.now() - this.passAt });
|
|
118
|
+
this.hung = true;
|
|
119
|
+
return ((this.again = true), this.polling);
|
|
120
|
+
}
|
|
121
|
+
this.d.log({ event: "dialog-pass-stuck", step: this.step, ms: this.now() - this.passAt });
|
|
122
|
+
this.gen++;
|
|
123
|
+
this.released.add(this.polling);
|
|
124
|
+
this.polling = undefined;
|
|
125
|
+
// What the pass let go marked as sent may never have left (it waited at the post): the controllers get every open question again.
|
|
126
|
+
for (const d of this.open.values()) d.sent.clear();
|
|
127
|
+
}
|
|
128
|
+
const gen = this.gen;
|
|
129
|
+
const run: Promise<void> = (async () => {
|
|
84
130
|
do {
|
|
85
131
|
this.again = false;
|
|
132
|
+
this.passAt = this.now();
|
|
86
133
|
try {
|
|
87
|
-
await this.pass();
|
|
134
|
+
await this.pass(gen);
|
|
88
135
|
} catch (error) {
|
|
89
|
-
this.d.log({ event: "dialog-forward-failed", error: String((error as Error).message).slice(0, 200) });
|
|
136
|
+
this.d.log({ event: "dialog-forward-failed", step: this.step, error: String((error as Error).message).slice(0, 200) });
|
|
90
137
|
}
|
|
91
|
-
} while (this.again);
|
|
92
|
-
})().finally(() =>
|
|
138
|
+
} while (this.again && gen === this.gen);
|
|
139
|
+
})().finally(() => {
|
|
140
|
+
if (this.polling === run) this.polling = undefined;
|
|
141
|
+
this.released.delete(run);
|
|
142
|
+
if (!this.released.size) this.hung = false;
|
|
143
|
+
});
|
|
93
144
|
return (this.polling = run);
|
|
94
145
|
}
|
|
95
146
|
/** Whose unit asked: looked up until it is known, then kept. A lookup that fails leaves it unknown, which is the admins' alone. */
|
|
@@ -101,10 +152,27 @@ export class DialogForwarder {
|
|
|
101
152
|
private scopeOf(d: Open): Scope {
|
|
102
153
|
return this.d.dispatcher ? (d.who ? { device: d.who.device, ...(d.who.team !== undefined ? { team: d.who.team } : {}) } : {}) : EVERYONE;
|
|
103
154
|
}
|
|
104
|
-
|
|
155
|
+
/** Records where the pass in flight waits (`dialog-pass-stuck` names it); a pass let go as stuck no longer writes it. */
|
|
156
|
+
private at(gen: number, step: string): void {
|
|
157
|
+
if (gen === this.gen) this.step = step;
|
|
158
|
+
}
|
|
159
|
+
/** One line when the controllers the forwarder sees change in number or in whether the list is complete, so a box with nobody to tell says so once (and the first pass says how it starts). */
|
|
160
|
+
private count(controllers: number, known: boolean): void {
|
|
161
|
+
const line = `${controllers}/${known}`;
|
|
162
|
+
if (line === this.counted) return;
|
|
163
|
+
this.counted = line;
|
|
164
|
+
this.d.log({ event: "dialog-controllers", count: controllers, known, open: this.open.size });
|
|
165
|
+
}
|
|
166
|
+
private async pass(gen: number): Promise<void> {
|
|
105
167
|
const to = this.d.controllers();
|
|
168
|
+
this.count(to.length, this.d.controllersKnown?.() ?? true);
|
|
169
|
+
if (this.closes.size && !(await this.drain(gen))) return;
|
|
106
170
|
if (!to.length && !this.open.size) return;
|
|
107
|
-
if (to.length)
|
|
171
|
+
if (to.length) {
|
|
172
|
+
this.at(gen, "read");
|
|
173
|
+
await this.d.read();
|
|
174
|
+
if (gen !== this.gen) return;
|
|
175
|
+
}
|
|
108
176
|
for (const o of to.length ? this.d.hub.answerable() : []) {
|
|
109
177
|
const id = o.target.answer.dialog;
|
|
110
178
|
if (this.open.has(id) || this.skipped.has(id)) continue;
|
|
@@ -113,19 +181,33 @@ export class DialogForwarder {
|
|
|
113
181
|
this.skipped.add(id);
|
|
114
182
|
this.d.log({ event: "dialog-oversize", dialog: id, bytes: Buffer.byteLength(text) });
|
|
115
183
|
const unit = { key: o.key } as Open;
|
|
184
|
+
this.at(gen, "dispatcher");
|
|
116
185
|
await this.whose(unit);
|
|
117
|
-
|
|
186
|
+
this.at(gen, "notice");
|
|
187
|
+
await this.d.notice?.(`oversize/${id}`, { kind: "dialog-oversize", unit: o.key, text: `A question of ${this.labelOf(o.key, o.owner)} was not forwarded: it is ${Buffer.byteLength(text)} bytes, over the ${LIMITS.dialog} the link carries. It waits at the device.` }, this.scopeOf(unit));
|
|
188
|
+
if (gen !== this.gen) return;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (this.open.size >= MAX_OPEN) {
|
|
192
|
+
// The one case where a question is left unforwarded with no line of its own: the line says the room is used up, once until it frees.
|
|
193
|
+
if (!this.full) this.d.log({ event: "dialog-full", open: this.open.size });
|
|
194
|
+
this.full = true;
|
|
118
195
|
continue;
|
|
119
196
|
}
|
|
120
|
-
|
|
121
|
-
this.
|
|
197
|
+
this.open.set(id, { key: o.key, target: o.target, owner: o.owner, label: this.labelOf(o.key, o.owner), text, sent: new Map(), away: new Set(), claimed: false, since: this.now(), told: false });
|
|
198
|
+
this.d.log({ event: "dialog-seen", dialog: id, unit: o.key, owner: o.owner });
|
|
122
199
|
}
|
|
123
200
|
for (const [id, d] of [...this.open]) {
|
|
201
|
+
this.at(gen, "dispatcher");
|
|
124
202
|
await this.whose(d);
|
|
203
|
+
if (gen !== this.gen) return;
|
|
125
204
|
const state = this.d.hub.outcome(id);
|
|
126
205
|
if (!state.open) {
|
|
127
206
|
this.open.delete(id);
|
|
128
|
-
|
|
207
|
+
this.full = false;
|
|
208
|
+
this.d.log({ event: "dialog-ended", dialog: id, by: state.by, sent: d.sent.size });
|
|
209
|
+
for (const k of d.sent.keys()) this.hold(k, id, state.by);
|
|
210
|
+
if (!(await this.drain(gen))) return;
|
|
129
211
|
continue;
|
|
130
212
|
}
|
|
131
213
|
// An answer no session confirmed, and the dialog is still open a while after: it did not land, so the next valid answer may win and the controllers get the question open again.
|
|
@@ -140,11 +222,14 @@ export class DialogForwarder {
|
|
|
140
222
|
for (const k of this.d.controllersKnown?.() === false ? [] : [...d.sent.keys()]) {
|
|
141
223
|
if (mine.some((c) => `${c.team}\n${c.device}` === k)) continue;
|
|
142
224
|
d.sent.delete(k);
|
|
143
|
-
|
|
225
|
+
this.hold(k, id, "withdrawn");
|
|
226
|
+
if (!(await this.drain(gen))) return;
|
|
144
227
|
}
|
|
145
228
|
if (!d.told && !d.claimed && to.length && this.now() - d.since >= UNANSWERED_MS) {
|
|
146
229
|
// Marked told only once the notice reached somebody: with no admin at `dispatch` it is tried again at the next pass (g346-F005).
|
|
147
|
-
|
|
230
|
+
this.at(gen, "notice");
|
|
231
|
+
const sent = await this.d.notice?.(`unanswered/${id}`, { kind: "dialog-unanswered", unit: d.key, text: `A question of ${d.label} has waited ${Math.round((this.now() - d.since) / 60_000)} minutes for an answer.` }, this.scopeOf(d));
|
|
232
|
+
if (gen !== this.gen) return;
|
|
148
233
|
d.told = sent !== false;
|
|
149
234
|
}
|
|
150
235
|
// A question no controller can be told of (an unattributed unit, and no admin at `dispatch`) stays open and is never widened to the others; the box's own log says it waits.
|
|
@@ -160,16 +245,53 @@ export class DialogForwarder {
|
|
|
160
245
|
if (at !== undefined) continue;
|
|
161
246
|
} else if (at !== undefined && !d.away.delete(k) && this.now() - at < REFRESH_MS) continue;
|
|
162
247
|
d.sent.set(k, this.now());
|
|
163
|
-
|
|
248
|
+
this.at(gen, "post");
|
|
249
|
+
const posted = await this.send(k, "dialog-open", { dialogId: id, digest: d.target.answer.digest, owner: d.label, dialog: d.text });
|
|
250
|
+
// A pass let go no longer owns `sent`: the pass that replaced it has marked what it posted.
|
|
251
|
+
if (gen !== this.gen) return;
|
|
252
|
+
if (!posted) d.sent.delete(k);
|
|
164
253
|
}
|
|
165
254
|
}
|
|
166
255
|
for (const id of [...this.skipped]) if (!this.d.hub.outcome(id).open) this.skipped.delete(id);
|
|
167
256
|
}
|
|
257
|
+
/** Keeps a close to post: the oldest is given up when the room is used up. */
|
|
258
|
+
private hold(k: string, dialogId: string, by: string): void {
|
|
259
|
+
this.closes.set(`${k}\n${dialogId}`, { k, dialogId, by, at: this.now(), tries: 0 });
|
|
260
|
+
for (const [key, c] of this.closes) {
|
|
261
|
+
if (this.closes.size <= MAX_CLOSES) break;
|
|
262
|
+
this.closes.delete(key);
|
|
263
|
+
this.d.log({ event: "dialog-close-dropped", dialog: c.dialogId, reason: "full" });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
/** Posts the held closes, each removed once posted. One that failed `CLOSE_TRIES` times, or whose card has expired at the controller (`OPEN_STALE_MS`), is given up with a line. False when this pass was let go meanwhile. */
|
|
267
|
+
private async drain(gen: number): Promise<boolean> {
|
|
268
|
+
for (const [key, c] of [...this.closes]) {
|
|
269
|
+
if (this.now() - c.at > OPEN_STALE_MS) {
|
|
270
|
+
this.closes.delete(key);
|
|
271
|
+
this.d.log({ event: "dialog-close-dropped", dialog: c.dialogId, reason: "stale" });
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
this.at(gen, "post");
|
|
275
|
+
const posted = await this.send(c.k, "dialog-close", { dialogId: c.dialogId, by: c.by });
|
|
276
|
+
if (posted) this.closes.delete(key);
|
|
277
|
+
else if (++c.tries >= CLOSE_TRIES) {
|
|
278
|
+
this.closes.delete(key);
|
|
279
|
+
this.d.log({ event: "dialog-close-dropped", dialog: c.dialogId, reason: "failed" });
|
|
280
|
+
}
|
|
281
|
+
if (gen !== this.gen) return false;
|
|
282
|
+
}
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
168
285
|
private now = () => this.d.now?.() ?? Date.now();
|
|
286
|
+
/** What the controllers are told asks: the unit's name when it is known, else the owner of the dialog. */
|
|
287
|
+
private labelOf(key: string, owner: string): string {
|
|
288
|
+
return this.d.unit?.(key) || owner;
|
|
289
|
+
}
|
|
169
290
|
private async send(k: string, kind: "dialog-open" | "dialog-close", payload: DialogOpen | DialogClose): Promise<boolean> {
|
|
170
291
|
const [team, device] = k.split("\n") as [string, string];
|
|
171
292
|
try {
|
|
172
293
|
await this.d.post(team, device, kind, payload);
|
|
294
|
+
this.d.log({ event: "dialog-sent", kind, dialog: payload.dialogId, team, to: device });
|
|
173
295
|
return true;
|
|
174
296
|
} catch (error) {
|
|
175
297
|
this.d.log({ event: "dialog-post-failed", kind, dialog: payload.dialogId, error: String((error as Error).message).slice(0, 200) });
|
package/src/link/holder.ts
CHANGED
|
@@ -1129,6 +1129,7 @@ export class LinkHolder {
|
|
|
1129
1129
|
// Only an attended device shows a question; only an unattended member of the team asks one (the envelope check enforces the kind's sender rule, this keeps the receiver honest on its own).
|
|
1130
1130
|
if (!(this.o.attended?.() ?? true) || !sender || sender.attended) return void this.o.store.log({ event: "drop", team: run.team, reason: "dialog-open-unexpected", from: header.from, kind: "dialog-open" });
|
|
1131
1131
|
const fresh = this.dialogViews.put({ team: run.team, device: header.from, dialogId: p.dialogId, digest: p.digest, owner: p.owner, dialog: p.dialog, at: this.now() });
|
|
1132
|
+
this.o.store.log({ event: "dialog-in", team: run.team, from: header.from, dialog: p.dialogId, fresh });
|
|
1132
1133
|
if (fresh) this.o.notify(`Straw Link: ${sender.label} asks a question (${restrictLabel(p.owner)})`, "warning");
|
|
1133
1134
|
}
|
|
1134
1135
|
private takeDialogClose(header: Header, p: DialogClose): void {
|
package/src/server/dispatcher.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { allyLabel } from "../boardauto.ts";
|
|
1
2
|
import type { Pane } from "../herdr.ts";
|
|
2
3
|
|
|
3
|
-
// Whose unit a pane is (docs/specs/2026-11-05-link-shared-box.md, Who is told): the controller that dispatched the work done in it. The box has two kinds of unit that a controller starts: an ally run, whose pane the
|
|
4
|
+
// Whose unit a pane is (docs/specs/2026-11-05-link-shared-box.md, Who is told): the controller that dispatched the work done in it, and what the controller is told the unit is called (`unitLabel`). The box has two kinds of unit that a controller starts: an ally run, whose pane the
|
|
4
5
|
// server itself split off (and keeps in its own record), and a main a run opened with `boss_open_main`, in a tab of its own whose pane carries `sb_opened_by=link:<device id>` from before its pi starts. Everything else
|
|
5
6
|
// (the tower's own runs, a session the boss opened on the box) is nobody's. The token is Herdr state any process of the box's user can write, the same trust the main's close and the route table already place in it;
|
|
6
7
|
// this scope is a matter of whom the box bothers and whom it believes, not a wall between users of one account.
|
|
@@ -26,3 +27,17 @@ export function dispatcherLookup(d: { paneOf(key: string): string | undefined; l
|
|
|
26
27
|
return panes ? dispatcherOf(pane, panes, d.allyPanes()) : undefined;
|
|
27
28
|
};
|
|
28
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The roles whose name alone does not say what the unit is: `main - docs`, `subagent - scout`. The others (a tower, an outside pi) are their name. The separator is ASCII because the controller's holder writes the label into its notice through
|
|
33
|
+
* `restrictLabel` (letters, digits, space, `.`, `_`, `-`; anything else becomes `_`, cut at 40), so the separator survives it. The name does not always: the card shows it whole, the notice restricted.
|
|
34
|
+
*/
|
|
35
|
+
const KIND: Record<string, string> = { main: "main", member: "member", subagent: "subagent", ally: "ally run", run: "run" };
|
|
36
|
+
|
|
37
|
+
/** The unit's name for a controller, or nothing when the unit has none left after the scrub (a name that was all path or token is a bare mark). `found` is what the net's registry holds for the unit's key. */
|
|
38
|
+
export function unitLabel(found: { name: string; role: string } | undefined): string | undefined {
|
|
39
|
+
const name = allyLabel(found?.name);
|
|
40
|
+
if (!found || !/[\p{L}\p{N}]/u.test(name)) return undefined;
|
|
41
|
+
const kind = KIND[found.role];
|
|
42
|
+
return kind ? `${kind} - ${name}` : name;
|
|
43
|
+
}
|
package/src/server/guihost.ts
CHANGED
|
@@ -353,7 +353,7 @@ export function createGuiHost(o: GuiHostOptions) {
|
|
|
353
353
|
let forwardNet: ReturnType<typeof netSource> | undefined;
|
|
354
354
|
return {
|
|
355
355
|
/** The hub and a net reading that binds its dialogs, for the forwarder an unattended device runs. */
|
|
356
|
-
dialogs: () => ({ hub: theHub(), read: async () => void (await (forwardNet ??= netSource({ ...(o.tower ? { services: (workspace: string) => o.tower!.view(workspace) } : {}), run: cfg.run, world: cfg.world, sessions, hub: theHub() })).read()), /** The
|
|
356
|
+
dialogs: () => ({ hub: theHub(), read: async () => void (await (forwardNet ??= netSource({ ...(o.tower ? { services: (workspace: string) => o.tower!.view(workspace) } : {}), run: cfg.run, world: cfg.world, sessions, hub: theHub() })).read()), /** The unit the hub files dialogs under `key` (known after a `read`): its pane for the dispatcher, its name and role for the label a controller is told. */ lookup: (key: string) => forwardNet?.lookup(key) }),
|
|
357
357
|
/** At the server's start: the GUI on the recorded ports unless it was stopped on purpose (which only the boss's own command undoes). */
|
|
358
358
|
async start(): Promise<void> {
|
|
359
359
|
// The gates' tracking does not depend on the page: a GUI stopped on purpose, or one that could not bind, still has the Allows of the boss's yes in the terminal.
|
package/src/server/main.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { createAsks } from "./asks.ts";
|
|
|
18
18
|
import { cliBeside, createGuiHost, spawnRestart } from "./guihost.ts";
|
|
19
19
|
import { type IntercomModule, loadIntercom, LoaderError } from "./intercom.ts";
|
|
20
20
|
import { DialogForwarder } from "../link/dialogs-out.ts";
|
|
21
|
-
import { dispatcherLookup } from "./dispatcher.ts";
|
|
21
|
+
import { dispatcherLookup, unitLabel } from "./dispatcher.ts";
|
|
22
22
|
import { openTower } from "./opentower.ts";
|
|
23
23
|
import { createLinkService, type LinkService, type ServiceDeps } from "../link/service.ts";
|
|
24
24
|
import { createLog, tailLog } from "./log.ts";
|
|
@@ -563,7 +563,7 @@ export async function runServer(o: ServerOptions): Promise<ServerHandle> {
|
|
|
563
563
|
// pi-intercom: the server's own session, reconnecting with the sidecar's backoff (1, 2, 5, 10, then 30 s). While it is disconnected its identities are not live.
|
|
564
564
|
// The link holder (B3): the primary server only, built once the first connection is attached (the kit's channel needs a client). A computer with no team set up gets no channel, election, or files.
|
|
565
565
|
const dialogsOut = () => (forwarder ??= new DialogForwarder({
|
|
566
|
-
hub: guiHost!.dialogs().hub, read: guiHost!.dialogs().read, dispatcher: dispatcherLookup({ paneOf: (key) => guiHost!.dialogs().
|
|
566
|
+
hub: guiHost!.dialogs().hub, read: guiHost!.dialogs().read, dispatcher: dispatcherLookup({ paneOf: (key) => guiHost!.dialogs().lookup(key)?.pane, list: () => panes(cfg.run), allyPanes: () => towers?.runs.allyPanes() ?? [], now }), admin: (team, device) => link?.holder?.isAdmin(team, device) ?? false, controllers: () => link?.holder?.dialogControllers() ?? [], online: (team, device) => link?.holder?.deviceOnline(team, device) ?? false, controllersKnown: () => link?.holder?.controllersKnown() ?? false, unit: (key) => unitLabel(guiHost!.dialogs().lookup(key)),
|
|
567
567
|
post: async (team, device, kind, payload) => { if (!link?.holder) throw new Error("The link holder is not running"); await link.holder.sendDialog(team, device, kind, payload); },
|
|
568
568
|
log: ({ event, ...rest }) => log.line(String(event), Object.fromEntries(Object.entries(rest).map(([k, v]) => [k, typeof v === "number" || typeof v === "boolean" ? v : phrase(v, 200)]))),
|
|
569
569
|
now, notice: (key, notice, scope) => unitsOut().notify(key, notice, scope),
|
|
@@ -664,7 +664,7 @@ export async function runServer(o: ServerOptions): Promise<ServerHandle> {
|
|
|
664
664
|
dialogsOut().start();
|
|
665
665
|
// A controller that gained `dispatch` mid-dialog gets it, a dialog unanswered for 30 minutes is told, and each controller's unit-status goes out when it changed or a minute has passed.
|
|
666
666
|
timers.push(setInterval(() => void Promise.all([dialogsOut().tick(), unitsOut().tick()]), o.unitsMs ?? 15_000));
|
|
667
|
-
}
|
|
667
|
+
} else log.line("dialog-forwarder", { state: "skipped", primary: slot.primary, leaving: Boolean(leaving), phase });
|
|
668
668
|
|
|
669
669
|
// Ready: `restarting` goes, and the start-error of this attempt (and any before it) with it.
|
|
670
670
|
if (phase === "starting") {
|