dsh-session-messenger 0.4.2
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/LICENSE +21 -0
- package/README.md +125 -0
- package/coordinator.js +504 -0
- package/cordis.patch.yml +5 -0
- package/delivery.js +209 -0
- package/index.js +290 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/negotiation.js +415 -0
- package/package.json +45 -0
- package/registry.js +197 -0
- package/schemas.generated.js +290 -0
- package/storage.js +205 -0
- package/tools.js +290 -0
- package/util.js +227 -0
- package/write-guard.js +82 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// GENERATED by extract-specs.mjs from the host defineTool compiler. Do not edit by hand.
|
|
2
|
+
// Regenerate: node extract-specs.mjs --write Verify: node extract-specs.mjs --check
|
|
3
|
+
|
|
4
|
+
export const CLAIM_FILES_PARAMETERS = {
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"paths": {
|
|
8
|
+
"type": "array",
|
|
9
|
+
"description": "File paths you intend to modify (at most 50). Workspace-relative paths resolve against this session cwd; absolute and ~/ paths are kept.",
|
|
10
|
+
"items": {
|
|
11
|
+
"type": "string"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"ttl_seconds": {
|
|
15
|
+
"type": "number",
|
|
16
|
+
"description": "Claim lifetime in seconds; default 1800, clamped to [30, 86400]."
|
|
17
|
+
},
|
|
18
|
+
"note": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"description": "Short intent note shown to a conflicting session, e.g. \"refactoring exports\"."
|
|
21
|
+
},
|
|
22
|
+
"cwd": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "Optional explicit absolute workspace root used to resolve relative paths; defaults to the calling session cwd."
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"required": [
|
|
28
|
+
"paths"
|
|
29
|
+
]
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const CLAIM_FILES_OUTPUT = {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"additionalProperties": false,
|
|
35
|
+
"properties": {
|
|
36
|
+
"ok": {
|
|
37
|
+
"type": "boolean"
|
|
38
|
+
},
|
|
39
|
+
"sessionId": {
|
|
40
|
+
"type": "string"
|
|
41
|
+
},
|
|
42
|
+
"registered": {
|
|
43
|
+
"type": "boolean"
|
|
44
|
+
},
|
|
45
|
+
"expiresAt": {
|
|
46
|
+
"type": "integer"
|
|
47
|
+
},
|
|
48
|
+
"conflicts": {
|
|
49
|
+
"type": "array",
|
|
50
|
+
"items": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"additionalProperties": false,
|
|
53
|
+
"properties": {
|
|
54
|
+
"path": {
|
|
55
|
+
"type": "string"
|
|
56
|
+
},
|
|
57
|
+
"ownerSessionId": {
|
|
58
|
+
"type": "string"
|
|
59
|
+
},
|
|
60
|
+
"ownerLabel": {
|
|
61
|
+
"type": "string"
|
|
62
|
+
},
|
|
63
|
+
"ownerOrigin": {
|
|
64
|
+
"type": "string"
|
|
65
|
+
},
|
|
66
|
+
"expiresAt": {
|
|
67
|
+
"type": "integer"
|
|
68
|
+
},
|
|
69
|
+
"note": {
|
|
70
|
+
"type": "string"
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"required": [
|
|
74
|
+
"path",
|
|
75
|
+
"ownerSessionId",
|
|
76
|
+
"ownerLabel",
|
|
77
|
+
"ownerOrigin",
|
|
78
|
+
"expiresAt",
|
|
79
|
+
"note"
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"hint": {
|
|
84
|
+
"type": "string"
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
"required": [
|
|
88
|
+
"ok",
|
|
89
|
+
"sessionId",
|
|
90
|
+
"registered",
|
|
91
|
+
"expiresAt",
|
|
92
|
+
"conflicts",
|
|
93
|
+
"hint"
|
|
94
|
+
]
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const RELEASE_FILES_PARAMETERS = {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"properties": {
|
|
100
|
+
"paths": {
|
|
101
|
+
"type": "array",
|
|
102
|
+
"description": "Specific paths to release (absolute or relative to this session cwd). Omit to release everything claimed by this session.",
|
|
103
|
+
"items": {
|
|
104
|
+
"type": "string"
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const RELEASE_FILES_OUTPUT = {
|
|
111
|
+
"type": "object",
|
|
112
|
+
"additionalProperties": false,
|
|
113
|
+
"properties": {
|
|
114
|
+
"ok": {
|
|
115
|
+
"type": "boolean"
|
|
116
|
+
},
|
|
117
|
+
"sessionId": {
|
|
118
|
+
"type": "string"
|
|
119
|
+
},
|
|
120
|
+
"released": {
|
|
121
|
+
"type": "integer"
|
|
122
|
+
},
|
|
123
|
+
"hint": {
|
|
124
|
+
"type": "string"
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
"required": [
|
|
128
|
+
"ok",
|
|
129
|
+
"sessionId",
|
|
130
|
+
"released",
|
|
131
|
+
"hint"
|
|
132
|
+
]
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const SEND_TO_SESSION_PARAMETERS = {
|
|
136
|
+
"type": "object",
|
|
137
|
+
"properties": {
|
|
138
|
+
"target": {
|
|
139
|
+
"type": "string",
|
|
140
|
+
"description": "Target session id (exact, or a unique id prefix of at least 4 characters). On failure the tool lists reachable live sessions."
|
|
141
|
+
},
|
|
142
|
+
"content": {
|
|
143
|
+
"type": "string",
|
|
144
|
+
"description": "Message body for the target session. Be concrete: what you need, which files, and what you propose."
|
|
145
|
+
},
|
|
146
|
+
"mode": {
|
|
147
|
+
"type": "string",
|
|
148
|
+
"description": "'queue' (default) = appended as the target's next input, waiting for its current turn to end; 'steer' = inserted into the target's current step when it is running. Either mode starts a turn when the target is idle.",
|
|
149
|
+
"enum": [
|
|
150
|
+
"queue",
|
|
151
|
+
"steer"
|
|
152
|
+
]
|
|
153
|
+
},
|
|
154
|
+
"topic": {
|
|
155
|
+
"type": "string",
|
|
156
|
+
"description": "Optional short topic label, e.g. \"claim conflict on src/x.ts\"."
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
"required": [
|
|
160
|
+
"target",
|
|
161
|
+
"content"
|
|
162
|
+
]
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
export const SEND_TO_SESSION_OUTPUT = {
|
|
166
|
+
"type": "object",
|
|
167
|
+
"additionalProperties": false,
|
|
168
|
+
"properties": {
|
|
169
|
+
"ok": {
|
|
170
|
+
"type": "boolean"
|
|
171
|
+
},
|
|
172
|
+
"target": {
|
|
173
|
+
"type": "string"
|
|
174
|
+
},
|
|
175
|
+
"mode": {
|
|
176
|
+
"type": "string"
|
|
177
|
+
},
|
|
178
|
+
"delivered": {
|
|
179
|
+
"type": "boolean"
|
|
180
|
+
},
|
|
181
|
+
"detail": {
|
|
182
|
+
"type": "string"
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
"required": [
|
|
186
|
+
"ok",
|
|
187
|
+
"target",
|
|
188
|
+
"mode",
|
|
189
|
+
"delivered",
|
|
190
|
+
"detail"
|
|
191
|
+
]
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
export const NEGOTIATE_PARAMETERS = {
|
|
195
|
+
"type": "object",
|
|
196
|
+
"properties": {
|
|
197
|
+
"action": {
|
|
198
|
+
"type": "string",
|
|
199
|
+
"description": "offer/counter = propose resolution terms; accept = accept the pending offer of the peer; decline = refuse to negotiate; escalate = ask for human arbitration; status = read-only view of your open negotiations.",
|
|
200
|
+
"enum": [
|
|
201
|
+
"offer",
|
|
202
|
+
"counter",
|
|
203
|
+
"accept",
|
|
204
|
+
"decline",
|
|
205
|
+
"escalate",
|
|
206
|
+
"status"
|
|
207
|
+
]
|
|
208
|
+
},
|
|
209
|
+
"path": {
|
|
210
|
+
"type": "string",
|
|
211
|
+
"description": "Contested file path (absolute or workspace-relative to this session cwd)."
|
|
212
|
+
},
|
|
213
|
+
"corr": {
|
|
214
|
+
"type": "string",
|
|
215
|
+
"description": "Negotiation id from a [[DSH session-messenger · negotiate]] message; required when several negotiations are open for the same path."
|
|
216
|
+
},
|
|
217
|
+
"terms": {
|
|
218
|
+
"type": "object",
|
|
219
|
+
"description": "Resolution terms. Only the current claim holder may offer release-now / release-at; the writer may offer wait-until.",
|
|
220
|
+
"additionalProperties": false,
|
|
221
|
+
"properties": {
|
|
222
|
+
"action": {
|
|
223
|
+
"type": "string",
|
|
224
|
+
"description": "release-now = holder frees the claim immediately; release-at = holder frees it at terms.at (ISO-8601); wait-until = writer commits to wait until terms.at and is reminded then.",
|
|
225
|
+
"enum": [
|
|
226
|
+
"release-now",
|
|
227
|
+
"release-at",
|
|
228
|
+
"wait-until"
|
|
229
|
+
]
|
|
230
|
+
},
|
|
231
|
+
"at": {
|
|
232
|
+
"type": "string",
|
|
233
|
+
"description": "ISO-8601 timestamp (required for release-at / wait-until, within 60 minutes)."
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
"required": [
|
|
237
|
+
"action"
|
|
238
|
+
]
|
|
239
|
+
},
|
|
240
|
+
"message": {
|
|
241
|
+
"type": "string",
|
|
242
|
+
"description": "Optional free-text note delivered to the peer with this intent."
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
"required": [
|
|
246
|
+
"action",
|
|
247
|
+
"path"
|
|
248
|
+
]
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
export const NEGOTIATE_OUTPUT = {
|
|
252
|
+
"type": "object",
|
|
253
|
+
"additionalProperties": false,
|
|
254
|
+
"properties": {
|
|
255
|
+
"ok": {
|
|
256
|
+
"type": "boolean"
|
|
257
|
+
},
|
|
258
|
+
"negId": {
|
|
259
|
+
"type": "string"
|
|
260
|
+
},
|
|
261
|
+
"path": {
|
|
262
|
+
"type": "string"
|
|
263
|
+
},
|
|
264
|
+
"state": {
|
|
265
|
+
"type": "string"
|
|
266
|
+
},
|
|
267
|
+
"rounds": {
|
|
268
|
+
"type": "integer"
|
|
269
|
+
},
|
|
270
|
+
"deadline": {
|
|
271
|
+
"type": "integer"
|
|
272
|
+
},
|
|
273
|
+
"released": {
|
|
274
|
+
"type": "boolean"
|
|
275
|
+
},
|
|
276
|
+
"detail": {
|
|
277
|
+
"type": "string"
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
"required": [
|
|
281
|
+
"ok",
|
|
282
|
+
"negId",
|
|
283
|
+
"path",
|
|
284
|
+
"state",
|
|
285
|
+
"rounds",
|
|
286
|
+
"deadline",
|
|
287
|
+
"released",
|
|
288
|
+
"detail"
|
|
289
|
+
]
|
|
290
|
+
};
|
package/storage.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件自有存储:数据目录解析、单宿主进程所有权锁、原子 JSON 合并写。
|
|
3
|
+
*
|
|
4
|
+
* - 绝不写 session event(自定义事件类型会让 Session 拒绝重开),状态落在
|
|
5
|
+
* ~/.dsh/plugin-data/dsh-session-messenger/<profile>/。
|
|
6
|
+
* - 每个宿主进程的内存是真相;多个宿主进程(如 desktop 与 dsh web)若共享同一目录会
|
|
7
|
+
* 互相覆盖,因此用 owner.lock(O_EXCL + pid 存活检查)确保同一目录只有一个写入进程;
|
|
8
|
+
* 抢不到锁的进程退到 host-<pid>/ 隔离子目录并告警(它本来也看不到对方宿主的会话)。
|
|
9
|
+
* - 所有落盘统一走 JsonFile.write():tmp(随机名)+ rename 原子替换,飞行中再脏则
|
|
10
|
+
* 跑第二轮(合并写),保证最终落盘的是最后一次内存状态。
|
|
11
|
+
*/
|
|
12
|
+
import { promises as fsp, openSync, writeSync, closeSync, readFileSync, unlinkSync, mkdirSync, existsSync, copyFileSync } from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
export function defaultDataDir(config) {
|
|
18
|
+
if (config && typeof config.dataDir === 'string' && config.dataDir) return config.dataDir;
|
|
19
|
+
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh');
|
|
20
|
+
const profile = String(process.env.DSH_PROFILE || (config && config.profile) || 'default').replace(/[^\w.-]/g, '_');
|
|
21
|
+
return path.join(home, 'plugin-data', 'dsh-session-messenger', profile);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* v0.3 把状态放在 plugin-data/dsh-session-messenger/ 根目录;v0.4 按 profile 分目录。
|
|
26
|
+
* 首次激活时把旧文件**复制**(不移动,便于回滚)到新目录,已存在则不覆盖。
|
|
27
|
+
*/
|
|
28
|
+
export function migrateLegacyData(baseDir, logger) {
|
|
29
|
+
try {
|
|
30
|
+
const legacyDir = path.dirname(baseDir);
|
|
31
|
+
if (path.basename(legacyDir) !== 'dsh-session-messenger') return;
|
|
32
|
+
mkdirSync(baseDir, { recursive: true });
|
|
33
|
+
for (const file of ['claims.json', 'negotiations.json']) {
|
|
34
|
+
const from = path.join(legacyDir, file);
|
|
35
|
+
const to = path.join(baseDir, file);
|
|
36
|
+
if (existsSync(from) && !existsSync(to)) {
|
|
37
|
+
copyFileSync(from, to);
|
|
38
|
+
logger?.info?.(`[session-messenger] migrated legacy ${file} into ${baseDir}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} catch (error) {
|
|
42
|
+
logger?.warn?.(`[session-messenger] legacy data migration skipped: ${(error && error.message) || error}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function pidAlive(pid) {
|
|
47
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
48
|
+
try {
|
|
49
|
+
process.kill(pid, 0);
|
|
50
|
+
return true;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return !!(error && error.code === 'EPERM');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 取得目录所有权。返回 { dir, release }:dir 是本进程实际使用的数据目录。
|
|
58
|
+
* 同步实现:激活路径只调用一次,且必须在任何读写之前确定目录。
|
|
59
|
+
*/
|
|
60
|
+
export function acquireDataDir(baseDir, logger) {
|
|
61
|
+
mkdirSync(baseDir, { recursive: true });
|
|
62
|
+
const lockFile = path.join(baseDir, 'owner.lock');
|
|
63
|
+
const body = JSON.stringify({ pid: process.pid, at: Date.now() });
|
|
64
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
65
|
+
try {
|
|
66
|
+
const fd = openSync(lockFile, 'wx');
|
|
67
|
+
writeSync(fd, body);
|
|
68
|
+
closeSync(fd);
|
|
69
|
+
return { dir: baseDir, release: () => releaseLock(lockFile) };
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (!error || error.code !== 'EEXIST') throw error;
|
|
72
|
+
}
|
|
73
|
+
let owner = 0;
|
|
74
|
+
try {
|
|
75
|
+
owner = Number(JSON.parse(readFileSync(lockFile, 'utf8')).pid) || 0;
|
|
76
|
+
} catch {
|
|
77
|
+
owner = 0;
|
|
78
|
+
}
|
|
79
|
+
if (owner === process.pid || !pidAlive(owner)) {
|
|
80
|
+
// 自己(HMR 重新激活)或已死进程留下的锁:接管。
|
|
81
|
+
try {
|
|
82
|
+
unlinkSync(lockFile);
|
|
83
|
+
} catch {
|
|
84
|
+
/* raced; retry once */
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const isolated = path.join(baseDir, `host-${process.pid}`);
|
|
89
|
+
mkdirSync(isolated, { recursive: true });
|
|
90
|
+
logger?.warn?.(
|
|
91
|
+
`[session-messenger] data dir ${baseDir} is owned by live host pid ${owner}; this host uses isolated ${isolated} (claims are not shared across host processes)`
|
|
92
|
+
);
|
|
93
|
+
return { dir: isolated, release: () => undefined };
|
|
94
|
+
}
|
|
95
|
+
throw new Error(`could not acquire ${lockFile}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function releaseLock(lockFile) {
|
|
99
|
+
try {
|
|
100
|
+
const owner = Number(JSON.parse(readFileSync(lockFile, 'utf8')).pid) || 0;
|
|
101
|
+
if (owner === process.pid) unlinkSync(lockFile);
|
|
102
|
+
} catch {
|
|
103
|
+
/* already gone */
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class JsonFile {
|
|
108
|
+
constructor(file) {
|
|
109
|
+
this.file = file;
|
|
110
|
+
this.running = undefined;
|
|
111
|
+
this.dirty = false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async read() {
|
|
115
|
+
try {
|
|
116
|
+
return JSON.parse(await fsp.readFile(this.file, 'utf8'));
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error && error.code === 'ENOENT') return undefined;
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async writeOnce(snapshot) {
|
|
124
|
+
const tmp = `${this.file}.${process.pid}.${randomUUID()}.tmp`;
|
|
125
|
+
await fsp.mkdir(path.dirname(this.file), { recursive: true });
|
|
126
|
+
try {
|
|
127
|
+
await fsp.writeFile(tmp, JSON.stringify(snapshot(), null, 2), 'utf8');
|
|
128
|
+
await fsp.rename(tmp, this.file);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
await fsp.rm(tmp, { force: true }).catch(() => undefined);
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 合并写:snapshot 在每一轮写入时才求值,因此返回的 Promise 兑现时,
|
|
137
|
+
* 磁盘上已是「调用时刻或更新」的内存状态。
|
|
138
|
+
*/
|
|
139
|
+
write(snapshot) {
|
|
140
|
+
if (this.running) {
|
|
141
|
+
this.dirty = true;
|
|
142
|
+
this.pendingSnapshot = snapshot;
|
|
143
|
+
return this.running;
|
|
144
|
+
}
|
|
145
|
+
this.pendingSnapshot = snapshot;
|
|
146
|
+
this.running = (async () => {
|
|
147
|
+
try {
|
|
148
|
+
do {
|
|
149
|
+
this.dirty = false;
|
|
150
|
+
await this.writeOnce(this.pendingSnapshot);
|
|
151
|
+
} while (this.dirty);
|
|
152
|
+
} finally {
|
|
153
|
+
this.running = undefined;
|
|
154
|
+
}
|
|
155
|
+
})();
|
|
156
|
+
return this.running;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 运行状态文件(灰度观察用):status.json 记录版本、启动时间、设置与累计指标。
|
|
162
|
+
* 指标跨重启累计(启动时读回旧值,按 bootId 区分本次与历史),便于算误冻结率等比率。
|
|
163
|
+
*/
|
|
164
|
+
export class StatusFile {
|
|
165
|
+
constructor(dataDir, { version, settings, logger }) {
|
|
166
|
+
this.file = new JsonFile(path.join(dataDir, 'status.json'));
|
|
167
|
+
this.version = version;
|
|
168
|
+
this.settings = settings;
|
|
169
|
+
this.logger = logger;
|
|
170
|
+
this.startedAt = Date.now();
|
|
171
|
+
this.bootId = randomUUID().slice(0, 8);
|
|
172
|
+
this.previous = {};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async load() {
|
|
176
|
+
try {
|
|
177
|
+
const parsed = await this.file.read();
|
|
178
|
+
this.previous = parsed && parsed.lifetime && typeof parsed.lifetime === 'object' ? parsed.lifetime : {};
|
|
179
|
+
} catch {
|
|
180
|
+
this.previous = {};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
snapshot(metrics, extra = {}) {
|
|
185
|
+
const lifetime = { ...this.previous };
|
|
186
|
+
for (const [k, v] of Object.entries(metrics)) lifetime[k] = (Number(this.previous[k]) || 0) + v;
|
|
187
|
+
return {
|
|
188
|
+
version: this.version,
|
|
189
|
+
pid: process.pid,
|
|
190
|
+
bootId: this.bootId,
|
|
191
|
+
startedAt: new Date(this.startedAt).toISOString(),
|
|
192
|
+
savedAt: new Date().toISOString(),
|
|
193
|
+
settings: this.settings,
|
|
194
|
+
boot: { ...metrics },
|
|
195
|
+
lifetime,
|
|
196
|
+
...extra
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
write(metrics, extra) {
|
|
201
|
+
return this.file.write(() => this.snapshot(metrics, extra)).catch((error) => {
|
|
202
|
+
this.logger?.warn?.(`[session-messenger] status write failed: ${(error && error.message) || error}`);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|