triflux 10.28.1 → 10.29.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.
- package/hooks/session-end-cleanup.mjs +15 -0
- package/hooks/session-start-fast.mjs +123 -0
- package/hub/server.mjs +44 -1
- package/hub/team/synapse-http.mjs +114 -13
- package/hub/team/synapse-registry.mjs +187 -10
- package/hub/workers/lib/jsonrpc-core.mjs +299 -0
- package/hub/workers/lib/jsonrpc-stdio.mjs +17 -276
- package/hub/workers/lib/jsonrpc-ws-uds.mjs +27 -234
- package/hud/constants.mjs +15 -0
- package/hud/hud-qos-status.mjs +3 -1
- package/hud/providers/claude.mjs +116 -14
- package/hud/renderers.mjs +39 -3
- package/package.json +1 -1
- package/scripts/pack.mjs +4 -0
- package/scripts/release/check-packages-mirror.mjs +47 -0
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { execFileSync } from "node:child_process";
|
|
10
10
|
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
import { unregisterSynapseSession } from "../hub/team/synapse-http.mjs";
|
|
12
13
|
|
|
13
14
|
const MARKER = "[session-end-cleanup]";
|
|
14
15
|
const STALE_MS = 30 * 60 * 1000;
|
|
@@ -292,6 +293,20 @@ function buildOutput(summary, cleanup, tmuxReport) {
|
|
|
292
293
|
export function run(input, env = process.env) {
|
|
293
294
|
if (input?.hook_event_name !== "SessionEnd") return "";
|
|
294
295
|
|
|
296
|
+
// Best-effort Synapse unregister (fire-and-forget). Crash-safe: if this hook
|
|
297
|
+
// never fires, the 5-min interactive TTL eventually marks the row stale, so
|
|
298
|
+
// unregister is an optimization, not a correctness dependency. Never throws,
|
|
299
|
+
// never changes the report-only stdout contract below.
|
|
300
|
+
//
|
|
301
|
+
// Bounded timeout (unref AbortController inside synapse-http) so a stalled hub
|
|
302
|
+
// cannot keep this short-lived SessionEnd hook process alive past its budget.
|
|
303
|
+
try {
|
|
304
|
+
const sessionId = String(input?.session_id || "").trim();
|
|
305
|
+
if (sessionId) unregisterSynapseSession(sessionId, { timeoutMs: 1500 });
|
|
306
|
+
} catch {
|
|
307
|
+
/* swallow — report-only charter preserved */
|
|
308
|
+
}
|
|
309
|
+
|
|
295
310
|
const root = env.CLAUDE_CWD || process.cwd();
|
|
296
311
|
const statePath = join(root, ".triflux", "subagents", "subagents.json");
|
|
297
312
|
if (!existsSync(statePath)) return "";
|
|
@@ -10,8 +10,14 @@
|
|
|
10
10
|
//
|
|
11
11
|
// external source 훅 (session-vault 등)은 여전히 execFile로 실행된다.
|
|
12
12
|
|
|
13
|
+
import { execFile } from "node:child_process";
|
|
13
14
|
import { dirname, join } from "node:path";
|
|
14
15
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
|
+
import {
|
|
17
|
+
drainPendingSynapse,
|
|
18
|
+
heartbeatSynapseSession,
|
|
19
|
+
registerSynapseSession,
|
|
20
|
+
} from "../hub/team/synapse-http.mjs";
|
|
15
21
|
import { createModuleLogger } from "../scripts/lib/logger.mjs";
|
|
16
22
|
|
|
17
23
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -20,6 +26,111 @@ const importMod = (p) => import(pathToFileURL(p).href);
|
|
|
20
26
|
|
|
21
27
|
const log = createModuleLogger("session-start-fast");
|
|
22
28
|
|
|
29
|
+
/**
|
|
30
|
+
* SessionStart 페이로드(JSON 문자열)에서 session_id / cwd 를 추출한다.
|
|
31
|
+
* 파싱 실패는 빈 객체로 흡수한다 (BLOCKING path 에 절대 영향 없음).
|
|
32
|
+
*/
|
|
33
|
+
function parseStartPayload(stdinData) {
|
|
34
|
+
try {
|
|
35
|
+
const raw = typeof stdinData === "string" ? stdinData : "";
|
|
36
|
+
return raw.trim() ? JSON.parse(raw) : {};
|
|
37
|
+
} catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* cwd 기준 git 컨텍스트(worktree root / branch)를 best-effort, 비동기로 수집.
|
|
44
|
+
* execFileSync 와 달리 호출자(BACKGROUND)를 블로킹하지 않는다. 각 git 호출은
|
|
45
|
+
* tight timeout(1.5s) + 강제 kill 로 묶여 hub/디스크 stall 시에도 잔류하지 않는다.
|
|
46
|
+
* @param {string} cwd
|
|
47
|
+
* @param {(args:string[], cb:(out:string)=>void)=>void} [gitRunner] 테스트용 seam
|
|
48
|
+
* @returns {Promise<{ worktreePath: string, branch: string }>}
|
|
49
|
+
*/
|
|
50
|
+
function gitContextAsync(cwd, gitRunner = defaultGitRunner) {
|
|
51
|
+
const run = (args) =>
|
|
52
|
+
new Promise((resolve) => {
|
|
53
|
+
try {
|
|
54
|
+
gitRunner(cwd, args, (out) => resolve(out));
|
|
55
|
+
} catch {
|
|
56
|
+
resolve("");
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
return Promise.all([
|
|
60
|
+
run(["rev-parse", "--show-toplevel"]),
|
|
61
|
+
run(["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
62
|
+
]).then(([toplevel, branch]) => ({
|
|
63
|
+
worktreePath: toplevel || cwd,
|
|
64
|
+
branch,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 기본 git runner: execFile(async) + tight timeout + kill. 실패는 빈 문자열. */
|
|
69
|
+
function defaultGitRunner(cwd, args, cb) {
|
|
70
|
+
execFile(
|
|
71
|
+
"git",
|
|
72
|
+
args,
|
|
73
|
+
{
|
|
74
|
+
cwd,
|
|
75
|
+
encoding: "utf8",
|
|
76
|
+
timeout: 1500,
|
|
77
|
+
killSignal: "SIGKILL",
|
|
78
|
+
windowsHide: true,
|
|
79
|
+
},
|
|
80
|
+
(err, stdout) => {
|
|
81
|
+
cb(err ? "" : String(stdout || "").trim());
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 인터랙티브 세션을 Synapse 레지스트리에 self-register (fire-and-forget).
|
|
88
|
+
* hub 미응답이면 silent no-op. BLOCKING path 에 latency 0 — git 컨텍스트는
|
|
89
|
+
* 블로킹 경로 밖에서 비동기로 enrich 한다 (cwd 만으로 즉시 minimal register).
|
|
90
|
+
*
|
|
91
|
+
* pid 는 의도적으로 보내지 않는다: 이 훅 프로세스의 process.pid 는 short-lived
|
|
92
|
+
* 훅 PID 일 뿐 세션 PID 가 아니므로 오기재가 된다. liveness 는 TTL 이 담당한다.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} stdinData
|
|
95
|
+
* @param {object} [seams] 테스트용 injectable seam
|
|
96
|
+
* @param {Function} [seams.register] registerSynapseSession 대체
|
|
97
|
+
* @param {Function} [seams.heartbeat] heartbeatSynapseSession 대체
|
|
98
|
+
* @param {Function} [seams.gitRunner] git runner 대체
|
|
99
|
+
* @returns {void} (enrich 는 백그라운드에서 완료)
|
|
100
|
+
*/
|
|
101
|
+
export function registerInteractiveSession(stdinData, seams = {}) {
|
|
102
|
+
const register = seams.register || registerSynapseSession;
|
|
103
|
+
const heartbeat = seams.heartbeat || heartbeatSynapseSession;
|
|
104
|
+
const gitRunner = seams.gitRunner || defaultGitRunner;
|
|
105
|
+
try {
|
|
106
|
+
const payload = parseStartPayload(stdinData);
|
|
107
|
+
const sessionId = String(payload?.session_id || "").trim();
|
|
108
|
+
if (!sessionId) return;
|
|
109
|
+
const cwd = typeof payload?.cwd === "string" ? payload.cwd : process.cwd();
|
|
110
|
+
|
|
111
|
+
// 1) cwd 만으로 즉시 minimal register (블로킹 git 없음, latency 0).
|
|
112
|
+
register({
|
|
113
|
+
sessionId,
|
|
114
|
+
cwd,
|
|
115
|
+
worktreePath: cwd,
|
|
116
|
+
branch: "",
|
|
117
|
+
host: "local",
|
|
118
|
+
sessionKind: "interactive",
|
|
119
|
+
isRemote: false,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// 2) worktree/branch 는 블로킹 경로 밖에서 비동기 enrich → heartbeat partial.
|
|
123
|
+
gitContextAsync(cwd, gitRunner)
|
|
124
|
+
.then(({ worktreePath, branch }) => {
|
|
125
|
+
if (worktreePath === cwd && !branch) return; // 추가 정보 없음
|
|
126
|
+
heartbeat(sessionId, { worktreePath, branch });
|
|
127
|
+
})
|
|
128
|
+
.catch(() => {});
|
|
129
|
+
} catch {
|
|
130
|
+
/* best-effort — never affects session start */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
23
134
|
/**
|
|
24
135
|
* BLOCKING 훅을 순차 실행. 하나라도 throw하면 로그만 남기고 계속.
|
|
25
136
|
* @param {string} stdinData
|
|
@@ -174,6 +285,9 @@ function runBackground(stdinData) {
|
|
|
174
285
|
.then((mod) => mod.run(stdinData))
|
|
175
286
|
.catch(() => {}); // 완전 무시
|
|
176
287
|
|
|
288
|
+
// Synapse: 인터랙티브 세션 self-register (fire-and-forget, hub 미응답 무시)
|
|
289
|
+
registerInteractiveSession(stdinData);
|
|
290
|
+
|
|
177
291
|
// session-vault은 external source — hook-orchestrator가 execFile로 실행
|
|
178
292
|
}
|
|
179
293
|
|
|
@@ -195,6 +309,15 @@ export async function execute(stdinData, externalHooks = []) {
|
|
|
195
309
|
runDeferred(stdinData);
|
|
196
310
|
runBackground(stdinData);
|
|
197
311
|
|
|
312
|
+
// hook-orchestrator process.exit(0)s immediately after we return, which would
|
|
313
|
+
// drop the just-fired Synapse register POST before it flushes to the loopback
|
|
314
|
+
// socket (Node's exit abandons pending I/O). Drain it with a tight bound: the
|
|
315
|
+
// register is a fast localhost POST (hub was ensured up in the BLOCKING phase)
|
|
316
|
+
// and the ceiling caps a hub stall so SessionStart never hangs. The async
|
|
317
|
+
// git-enrich heartbeat stays best-effort (it may not have fired yet) — losing
|
|
318
|
+
// it only drops worktree/branch enrichment, not the core registration.
|
|
319
|
+
await drainPendingSynapse(1000);
|
|
320
|
+
|
|
198
321
|
const totalDur = performance.now() - totalStart;
|
|
199
322
|
log.info(
|
|
200
323
|
{
|
package/hub/server.mjs
CHANGED
|
@@ -56,7 +56,10 @@ import { createStoreAdapter } from "./store-adapter.mjs";
|
|
|
56
56
|
import { createGitPreflight } from "./team/git-preflight.mjs";
|
|
57
57
|
import { nativeProxy } from "./team/nativeProxy.mjs";
|
|
58
58
|
import { createSwarmLocks } from "./team/swarm-locks.mjs";
|
|
59
|
-
import {
|
|
59
|
+
import {
|
|
60
|
+
createSynapseRegistry,
|
|
61
|
+
projectPeer,
|
|
62
|
+
} from "./team/synapse-registry.mjs";
|
|
60
63
|
import { registerTeamBridge } from "./team-bridge.mjs";
|
|
61
64
|
import { createTools } from "./tools.mjs";
|
|
62
65
|
import { createDelegatorMcpWorker } from "./workers/delegator-mcp.mjs";
|
|
@@ -1299,7 +1302,20 @@ export async function startHub({
|
|
|
1299
1302
|
}
|
|
1300
1303
|
|
|
1301
1304
|
// ── Synapse Layer 5: session registry + locks + preflight routes ──
|
|
1305
|
+
// Admin/raw snapshot (loopback-only). Returns raw cwd/pid for local
|
|
1306
|
+
// admin/HUD use; the redacted peer surface is GET /synapse/peers.
|
|
1302
1307
|
if (path === "/synapse/sessions" && req.method === "GET") {
|
|
1308
|
+
// Enforce the loopback boundary independent of token mode: the global
|
|
1309
|
+
// token gate admits any token-bearing remote client, but this raw
|
|
1310
|
+
// snapshot leaks absolute cwd/pid/worktreePath/dirtyFiles for every
|
|
1311
|
+
// session, so it must never be reachable off 127.0.0.1 (LOCKED #3: raw
|
|
1312
|
+
// cwd is admin-only via loopback). Off-loopback peers use /synapse/peers.
|
|
1313
|
+
if (!isLoopbackRemoteAddress(req.socket.remoteAddress)) {
|
|
1314
|
+
return writeJson(res, 403, {
|
|
1315
|
+
ok: false,
|
|
1316
|
+
error: "Forbidden: /synapse/sessions is loopback-only",
|
|
1317
|
+
});
|
|
1318
|
+
}
|
|
1303
1319
|
return writeJson(res, 200, {
|
|
1304
1320
|
ok: true,
|
|
1305
1321
|
...synapseRegistry.snapshot(),
|
|
@@ -1307,6 +1323,33 @@ export async function startHub({
|
|
|
1307
1323
|
});
|
|
1308
1324
|
}
|
|
1309
1325
|
|
|
1326
|
+
// Redacted peer-discovery surface. Returns co-located live peers (same
|
|
1327
|
+
// cwd / worktree) with raw cwd/pid stripped — only label/hash + booleans.
|
|
1328
|
+
if (path === "/synapse/peers" && req.method === "GET") {
|
|
1329
|
+
// formatHostForUrl wraps IPv6 hosts in [...]; a bare `::1` would make
|
|
1330
|
+
// `new URL(req.url, "http://::1")` throw (invalid authority).
|
|
1331
|
+
const query = new URL(req.url, `http://${formatHostForUrl(host)}`)
|
|
1332
|
+
.searchParams;
|
|
1333
|
+
const cwd = query.get("cwd") || "";
|
|
1334
|
+
const worktree = query.get("worktree") || "";
|
|
1335
|
+
const excludeSessionId = query.get("excludeSessionId") || "";
|
|
1336
|
+
// Require at least one non-empty locator. Without it querySessions
|
|
1337
|
+
// already returns [], but short-circuiting keeps the contract explicit
|
|
1338
|
+
// and avoids ever enumerating the registry over the redacted surface.
|
|
1339
|
+
if (!cwd && !worktree) {
|
|
1340
|
+
return writeJson(res, 200, { ok: true, peers: [], ts: Date.now() });
|
|
1341
|
+
}
|
|
1342
|
+
const matches = synapseRegistry.querySessions({
|
|
1343
|
+
cwd,
|
|
1344
|
+
worktree,
|
|
1345
|
+
excludeSessionId,
|
|
1346
|
+
});
|
|
1347
|
+
const peers = matches.map((session) =>
|
|
1348
|
+
projectPeer(session, { cwd, worktree }),
|
|
1349
|
+
);
|
|
1350
|
+
return writeJson(res, 200, { ok: true, peers, ts: Date.now() });
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1310
1353
|
if (path === "/synapse/register" && req.method === "POST") {
|
|
1311
1354
|
try {
|
|
1312
1355
|
const body = await parseBody(req);
|
|
@@ -1,4 +1,56 @@
|
|
|
1
|
-
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const HUB_DEFAULT_PORT = 27888;
|
|
6
|
+
// Same token file as hub/bridge.mjs (HUB_TOKEN_FILE). Kept inline rather than
|
|
7
|
+
// imported so this helper stays dependency-free and byte-identical across the
|
|
8
|
+
// packages/core mirror (which cannot carry relative cross-package imports).
|
|
9
|
+
const HUB_TOKEN_FILE = join(homedir(), ".claude", ".tfx-hub-token");
|
|
10
|
+
|
|
11
|
+
// In-flight fire-and-forget POSTs. A short-lived caller (the SessionStart hook,
|
|
12
|
+
// which process.exit(0)s immediately after firing register) must be able to
|
|
13
|
+
// flush these before the process dies: Node's process.exit() abandons pending
|
|
14
|
+
// socket I/O, so an un-awaited loopback POST is created but never written.
|
|
15
|
+
// Callers on an exit budget drain this set via drainPendingSynapse() first.
|
|
16
|
+
const inFlightSynapse = new Set();
|
|
17
|
+
|
|
18
|
+
function normalizeToken(raw) {
|
|
19
|
+
if (raw == null) return null;
|
|
20
|
+
const token = String(raw).trim();
|
|
21
|
+
return token || null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Mirrors hub/bridge.mjs:readHubToken — env override first, then the token file.
|
|
25
|
+
// Missing token → null (backward-compatible with token-less hubs).
|
|
26
|
+
function readHubToken() {
|
|
27
|
+
const envToken = normalizeToken(process.env.TFX_HUB_TOKEN);
|
|
28
|
+
if (envToken) return envToken;
|
|
29
|
+
try {
|
|
30
|
+
return normalizeToken(readFileSync(HUB_TOKEN_FILE, "utf8"));
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Wrap IPv6 hosts in [...] so they form a valid URL authority (cf. bridge.mjs).
|
|
37
|
+
function formatHostForUrl(host) {
|
|
38
|
+
return String(host).includes(":") ? `[${host}]` : host;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Resolve the hub base URL from env (TFX_HUB_URL / TFX_HUB_PORT) before falling
|
|
42
|
+
// back to the loopback default, so a cascaded / non-default hub port is hit
|
|
43
|
+
// instead of silently posting to 127.0.0.1:27888 and missing the live hub.
|
|
44
|
+
function resolveSynapseBaseUrl() {
|
|
45
|
+
const envUrl = normalizeToken(process.env.TFX_HUB_URL);
|
|
46
|
+
if (envUrl) return envUrl.replace(/\/mcp$/, "");
|
|
47
|
+
|
|
48
|
+
const envPort = Number.parseInt(String(process.env.TFX_HUB_PORT ?? ""), 10);
|
|
49
|
+
if (Number.isFinite(envPort) && envPort > 0) {
|
|
50
|
+
return `http://${formatHostForUrl("127.0.0.1")}:${envPort}`;
|
|
51
|
+
}
|
|
52
|
+
return `http://${formatHostForUrl("127.0.0.1")}:${HUB_DEFAULT_PORT}`;
|
|
53
|
+
}
|
|
2
54
|
|
|
3
55
|
function resolveSynapseFetch(fetchImpl) {
|
|
4
56
|
if (fetchImpl === null) return null;
|
|
@@ -19,23 +71,68 @@ export function fireAndForgetSynapse(path, payload, opts = {}) {
|
|
|
19
71
|
if (!fetchImpl) return false;
|
|
20
72
|
|
|
21
73
|
try {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
74
|
+
const baseUrl = opts.baseUrl || resolveSynapseBaseUrl();
|
|
75
|
+
const url = new URL(path, baseUrl).toString();
|
|
76
|
+
const headers = { "content-type": "application/json" };
|
|
77
|
+
// token: false explicitly skips auth; otherwise reuse the hub token so a
|
|
78
|
+
// token-required hub does not 401 (the failure would be swallowed silently).
|
|
79
|
+
if (opts.token !== false) {
|
|
80
|
+
const token =
|
|
81
|
+
typeof opts.token === "string" && opts.token.trim()
|
|
82
|
+
? opts.token.trim()
|
|
83
|
+
: readHubToken();
|
|
84
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
85
|
+
}
|
|
86
|
+
const init = {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers,
|
|
89
|
+
body: JSON.stringify(payload),
|
|
90
|
+
};
|
|
91
|
+
// Optional bounded timeout so callers on a teardown budget (e.g. SessionEnd)
|
|
92
|
+
// cannot hang the host process if the hub stalls. The timer is unref'd so it
|
|
93
|
+
// never keeps the event loop (and thus the hook process) alive on its own.
|
|
94
|
+
let timer = null;
|
|
95
|
+
const timeoutMs = Number(opts.timeoutMs);
|
|
96
|
+
if (
|
|
97
|
+
Number.isFinite(timeoutMs) &&
|
|
98
|
+
timeoutMs > 0 &&
|
|
99
|
+
typeof AbortController === "function"
|
|
100
|
+
) {
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
init.signal = controller.signal;
|
|
103
|
+
timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
104
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
105
|
+
}
|
|
106
|
+
const tracked = Promise.resolve(fetchImpl(url, init))
|
|
107
|
+
.catch(() => {})
|
|
108
|
+
.finally(() => {
|
|
109
|
+
if (timer) clearTimeout(timer);
|
|
110
|
+
inFlightSynapse.delete(tracked);
|
|
111
|
+
});
|
|
112
|
+
inFlightSynapse.add(tracked);
|
|
33
113
|
return true;
|
|
34
114
|
} catch {
|
|
35
115
|
return false;
|
|
36
116
|
}
|
|
37
117
|
}
|
|
38
118
|
|
|
119
|
+
// Await any in-flight fire-and-forget Synapse POSTs, bounded by timeoutMs so a
|
|
120
|
+
// stalled hub can never hang the caller. The SessionStart hook calls this right
|
|
121
|
+
// before process.exit(0) so the register POST actually flushes to the socket.
|
|
122
|
+
export function drainPendingSynapse(timeoutMs = 1000) {
|
|
123
|
+
if (inFlightSynapse.size === 0) return Promise.resolve();
|
|
124
|
+
const settled = Promise.allSettled([...inFlightSynapse]);
|
|
125
|
+
const ms = Number(timeoutMs);
|
|
126
|
+
if (!Number.isFinite(ms) || ms <= 0) return settled;
|
|
127
|
+
return Promise.race([
|
|
128
|
+
settled,
|
|
129
|
+
new Promise((resolve) => {
|
|
130
|
+
const t = setTimeout(resolve, ms);
|
|
131
|
+
if (typeof t.unref === "function") t.unref();
|
|
132
|
+
}),
|
|
133
|
+
]);
|
|
134
|
+
}
|
|
135
|
+
|
|
39
136
|
export function registerSynapseSession(meta, opts = {}) {
|
|
40
137
|
return fireAndForgetSynapse("/synapse/register", meta, opts);
|
|
41
138
|
}
|
|
@@ -45,11 +142,15 @@ export function heartbeatSynapseSession(
|
|
|
45
142
|
partialMeta = {},
|
|
46
143
|
opts = {},
|
|
47
144
|
) {
|
|
145
|
+
// The live route reads `{ sessionId, partial }` (hub/server.mjs
|
|
146
|
+
// /synapse/heartbeat → synapseRegistry.heartbeat(sessionId, partial)), so the
|
|
147
|
+
// helper must nest the meta under `partial` rather than spreading it top-level.
|
|
48
148
|
return fireAndForgetSynapse(
|
|
49
149
|
"/synapse/heartbeat",
|
|
50
150
|
{
|
|
51
151
|
sessionId,
|
|
52
|
-
|
|
152
|
+
partial:
|
|
153
|
+
partialMeta && typeof partialMeta === "object" ? partialMeta : {},
|
|
53
154
|
},
|
|
54
155
|
opts,
|
|
55
156
|
);
|