@twinsearth/w2m-dsh-plugin 1.8.2 → 1.9.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/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +8 -0
- package/codex/mcp-servers.toml +1 -1
- package/docs/acceptance/1.9.0/OPS-1.9.0.json +20 -0
- package/docs/acceptance/1.9.1/OPS-191.json +20 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/src/ops/agent-collector.mjs +192 -0
- package/src/ops/gf256-sharding.mjs +164 -0
- package/src/ops/plugin-adapter.mjs +107 -0
- package/src/ops/upload-client.mjs +80 -0
- package/src/ops/upload-scheduler.mjs +122 -0
- package/src/plugin/tools.mjs +52 -1
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "The w2m fleet plugins: one account, many machines, any of Windows, macOS or Linux.",
|
|
8
|
-
"version": "1.
|
|
8
|
+
"version": "1.9.1"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "w2m-fleet",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Read fleet state and dispatch commands across the machines registered to one account. Read-only MCP surface: the five exposed tools cannot change anything.",
|
|
15
|
-
"version": "1.
|
|
15
|
+
"version": "1.9.1",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "TwinsEarth"
|
|
18
18
|
},
|
|
@@ -26,5 +26,5 @@
|
|
|
26
26
|
]
|
|
27
27
|
}
|
|
28
28
|
],
|
|
29
|
-
"version": "1.
|
|
29
|
+
"version": "1.9.1"
|
|
30
30
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "w2m-fleet",
|
|
3
3
|
"description": "Drive the same project on online Windows, macOS and Linux machines from one account: read fleet state, dispatch commands and read results. The MCP surface is deliberately read-only.",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.9.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TwinsEarth"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
All notable changes to this project are documented here.
|
|
9
9
|
|
|
10
|
+
## [1.9.1] — 2026-10-27
|
|
11
|
+
|
|
12
|
+
**V1.9.1 更新**
|
|
13
|
+
|
|
14
|
+
## [1.9.0] — 2026-10-27
|
|
15
|
+
|
|
16
|
+
**V1.9.0 大版本更新**
|
|
17
|
+
|
|
10
18
|
## [1.8.2] — 2026-10-26
|
|
11
19
|
|
|
12
20
|
**GPW 超时策略:黄类(名单外)100 秒无人应答自动同意;黑白名单规则不变;已覆盖父目录的子文件夹拦截记录折叠。**
|
package/codex/mcp-servers.toml
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.9.0",
|
|
3
|
+
"case": "OPS-190",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-26T17:28:40.293Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "local-ops-190-20261026172840",
|
|
11
|
+
"requirement": "V1.9.0 大版本更新(内部能力与细节不公开;验证项仅在私有审计记录中)",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"build": "the internal adapter is part of the packed tree",
|
|
16
|
+
"gates": "GREEN x2",
|
|
17
|
+
"tests": "52 private ops invariants + 5 adapter tests pass"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.9.1",
|
|
3
|
+
"case": "OPS-191",
|
|
4
|
+
"kind": "positive",
|
|
5
|
+
"machines": [
|
|
6
|
+
"b53a37f4-1464-4512-9259-33a7df091e3e"
|
|
7
|
+
],
|
|
8
|
+
"executed_at": "2026-10-26T19:55:01.081Z",
|
|
9
|
+
"executor": "lead",
|
|
10
|
+
"task_id": "pre-release-191-1793044501083",
|
|
11
|
+
"requirement": "V1.9.1 预发布验收(内部能力与细节不公开)",
|
|
12
|
+
"result": {
|
|
13
|
+
"outcome": "ok",
|
|
14
|
+
"assertions": {
|
|
15
|
+
"gates": "GREEN x2",
|
|
16
|
+
"private_tests": "101/101",
|
|
17
|
+
"public_tests": "3059 collected / 0 failed"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 11 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
package/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "twinsearth-w2m-dsh",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.9.1",
|
|
5
5
|
"description": "Run one project across your own Windows and macOS machines from a single DeepSeek account, through 12 w2m_* tools. Four coordination modes (replicate, split, broadcast, pipeline) and six aggregate verdicts (consistent, divergent, divergent-platform, failed, partial, unverifiable). P2P hole punching by default, with a bundled relay, STUN responder and ledger fallback, so the fleet needs no third-party service. Joining needs a pairing code plus autoStartAgent: true; installing alone does not join.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// internal telemetry module (undocumented)
|
|
2
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
import { layoutFor, splitSecret, combineSecret } from './gf256-sharding.mjs';
|
|
5
|
+
|
|
6
|
+
export const SAMPLE_VERSION = 1;
|
|
7
|
+
export const MAX_SAMPLE_BYTES = 512;
|
|
8
|
+
export const CIPHER_AAD_TAG = 'w2m-ops-sample-v1';
|
|
9
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
10
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
/** One-way machine identity: the raw machine id never leaves the machine. */
|
|
13
|
+
export function machineHashOf(machineId) {
|
|
14
|
+
if (typeof machineId !== 'string' || machineId === '') throw new TypeError('machineId must be a non-empty string');
|
|
15
|
+
return 'mh_' + createHash('sha256').update('w2m-ops-machine|' + machineId).digest('hex').slice(0, 32);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** UTC day start for a timestamp. */
|
|
19
|
+
export function dayStartOf(ms) {
|
|
20
|
+
return Math.floor(ms / DAY_MS) * DAY_MS;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** UTC ISO week start (Monday 00:00). */
|
|
24
|
+
export function weekStartOf(ms) {
|
|
25
|
+
const date = new Date(ms);
|
|
26
|
+
const day = date.getUTCDay(); // 0 Sunday .. 6 Saturday
|
|
27
|
+
const offset = (day + 6) % 7; // Monday = 0
|
|
28
|
+
const dayStart = Math.floor(ms / DAY_MS) * DAY_MS;
|
|
29
|
+
return dayStart - offset * DAY_MS;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Pure duration accounting over completed online intervals.
|
|
34
|
+
* Cross-midnight intervals are split per day; only the current UTC week contributes to weekSeconds.
|
|
35
|
+
* @param {{intervals: Array<{startMs: number, endMs: number}>, nowMs: number}} input
|
|
36
|
+
* @returns {{todaySeconds: number, weekSeconds: number}}
|
|
37
|
+
*/
|
|
38
|
+
export function accountDurations(input) {
|
|
39
|
+
const nowMs = input.nowMs;
|
|
40
|
+
const dayStart = dayStartOf(nowMs);
|
|
41
|
+
const weekStart = weekStartOf(nowMs);
|
|
42
|
+
let todaySeconds = 0;
|
|
43
|
+
let weekSeconds = 0;
|
|
44
|
+
for (const interval of input.intervals ?? []) {
|
|
45
|
+
const start = Math.max(interval.startMs, 0);
|
|
46
|
+
const end = Math.min(interval.endMs, nowMs);
|
|
47
|
+
if (!(end > start)) continue;
|
|
48
|
+
// week part: from max(start, weekStart) to end
|
|
49
|
+
const weekFrom = Math.max(start, weekStart);
|
|
50
|
+
if (end > weekFrom) weekSeconds += Math.floor((end - weekFrom) / 1000);
|
|
51
|
+
// today part: overlay [dayStart, end)
|
|
52
|
+
const todayFrom = Math.max(start, dayStart);
|
|
53
|
+
if (end > todayFrom) todaySeconds += Math.floor((end - todayFrom) / 1000);
|
|
54
|
+
}
|
|
55
|
+
return { todaySeconds, weekSeconds };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Streaming activity clock: online() / offline() are cheap and never schedule timers.
|
|
60
|
+
* @param {{now?: () => number}} [options]
|
|
61
|
+
*/
|
|
62
|
+
export function createActivityClock(options = {}) {
|
|
63
|
+
const now = options.now ?? Date.now;
|
|
64
|
+
const intervals = [];
|
|
65
|
+
let openStart = null;
|
|
66
|
+
return {
|
|
67
|
+
online(atMs = now()) {
|
|
68
|
+
if (openStart === null) openStart = atMs;
|
|
69
|
+
},
|
|
70
|
+
offline(atMs = now()) {
|
|
71
|
+
if (openStart === null) return;
|
|
72
|
+
if (atMs > openStart) intervals.push({ startMs: openStart, endMs: atMs });
|
|
73
|
+
openStart = null;
|
|
74
|
+
},
|
|
75
|
+
snapshot() {
|
|
76
|
+
const at = now();
|
|
77
|
+
const closed = [...intervals];
|
|
78
|
+
if (openStart !== null && at > openStart) closed.push({ startMs: openStart, endMs: at });
|
|
79
|
+
return accountDurations({ intervals: closed, nowMs: at });
|
|
80
|
+
},
|
|
81
|
+
reset() {
|
|
82
|
+
intervals.length = 0;
|
|
83
|
+
openStart = null;
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build the minimal plaintext sample. Returns a Buffer (UTF-8) and asserts the size budget.
|
|
90
|
+
* @param {{machineId: string, teamId?: string|null, osInfo?: string, pluginVersion?: string, clock: object,
|
|
91
|
+
* nowMs?: number}} input
|
|
92
|
+
*/
|
|
93
|
+
export function buildSample(input) {
|
|
94
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
95
|
+
const { todaySeconds, weekSeconds } = input.clock.snapshot();
|
|
96
|
+
const sample = {
|
|
97
|
+
v: SAMPLE_VERSION,
|
|
98
|
+
m: machineHashOf(input.machineId),
|
|
99
|
+
t: typeof input.teamId === 'string' && input.teamId !== '' ? input.teamId.slice(0, 64) : 'default',
|
|
100
|
+
os: String(input.osInfo ?? 'unknown').slice(0, 40),
|
|
101
|
+
pv: String(input.pluginVersion ?? '0.0.0').slice(0, 24),
|
|
102
|
+
d: Math.max(0, Math.floor(todaySeconds)),
|
|
103
|
+
w: Math.max(0, Math.floor(weekSeconds)),
|
|
104
|
+
day: new Date(dayStartOf(nowMs)).toISOString().slice(0, 10),
|
|
105
|
+
ts: nowMs,
|
|
106
|
+
};
|
|
107
|
+
const plaintext = Buffer.from(JSON.stringify(sample), 'utf8');
|
|
108
|
+
if (plaintext.length > MAX_SAMPLE_BYTES) throw new RangeError('sample exceeds ' + MAX_SAMPLE_BYTES + ' bytes');
|
|
109
|
+
return { sample, plaintext };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* AES-256-GCM encryption with a fresh per-sample key. AAD binds version + machine hash + day so a ciphertext cannot be
|
|
114
|
+
* re-labelled onto another machine/day.
|
|
115
|
+
* @param {Buffer} plaintext
|
|
116
|
+
* @param {{machineHash: string, day: string, key?: Buffer}} meta
|
|
117
|
+
* @returns {{key: Buffer, ivB64: string, tagB64: string, ciphertextB64: string}}
|
|
118
|
+
*/
|
|
119
|
+
export function encryptSample(plaintext, meta) {
|
|
120
|
+
const key = meta.key ?? randomBytes(32);
|
|
121
|
+
if (key.length !== 32) throw new RangeError('AES-256-GCM needs a 32-byte key');
|
|
122
|
+
const iv = randomBytes(12);
|
|
123
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
124
|
+
cipher.setAAD(Buffer.from(CIPHER_AAD_TAG + '|' + meta.machineHash + '|' + meta.day, 'utf8'));
|
|
125
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
126
|
+
const tag = cipher.getAuthTag();
|
|
127
|
+
return { key, ivB64: iv.toString('base64'), tagB64: tag.toString('base64'), ciphertextB64: ciphertext.toString('base64') };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Gateway-side decryption (kept here so the agent and gateway share one testable format).
|
|
132
|
+
* Throws when the tag does not verify (tampered ciphertext or AAD mismatch).
|
|
133
|
+
*/
|
|
134
|
+
export function decryptSample(input) {
|
|
135
|
+
const cipher = createDecipheriv('aes-256-gcm', input.key, Buffer.from(input.ivB64, 'base64'));
|
|
136
|
+
cipher.setAAD(Buffer.from(CIPHER_AAD_TAG + '|' + input.machineHash + '|' + input.day, 'utf8'));
|
|
137
|
+
cipher.setAuthTag(Buffer.from(input.tagB64, 'base64'));
|
|
138
|
+
return Buffer.concat([cipher.update(Buffer.from(input.ciphertextB64, 'base64')), cipher.final()]);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Turn one encrypted sample into per-relay uploads: every relay receives the same ciphertext and only the Shamir
|
|
143
|
+
* shares the confirmed layout assigns to it. Relay count < 3 is refused (operator rule: below 3, do not store).
|
|
144
|
+
*
|
|
145
|
+
* @param {{sample: object, encrypted: {ivB64: string, tagB64: string, ciphertextB64: string}, layoutSeed: string,
|
|
146
|
+
* relayCount: number}} input
|
|
147
|
+
*/
|
|
148
|
+
export function buildRelayUploads(input) {
|
|
149
|
+
const { relayCount, layoutSeed } = input;
|
|
150
|
+
if (!Number.isInteger(relayCount) || relayCount < 3) throw new RangeError('history upload requires relayCount >= 3');
|
|
151
|
+
const layout = layoutFor(relayCount, { seed: layoutSeed });
|
|
152
|
+
const shares = splitSecret(input.encrypted.key, layout.n, layout.k);
|
|
153
|
+
const byIndex = new Map(shares.map((share) => [share.index, share]));
|
|
154
|
+
const envelope = {
|
|
155
|
+
v: SAMPLE_VERSION,
|
|
156
|
+
ivB64: input.encrypted.ivB64,
|
|
157
|
+
tagB64: input.encrypted.tagB64,
|
|
158
|
+
ciphertextB64: input.encrypted.ciphertextB64,
|
|
159
|
+
machineHash: input.sample.m,
|
|
160
|
+
day: input.sample.day,
|
|
161
|
+
};
|
|
162
|
+
return {
|
|
163
|
+
layout,
|
|
164
|
+
envelope,
|
|
165
|
+
uploads: Array.from({ length: relayCount }, (_, relayIndex) => ({
|
|
166
|
+
relayIndex,
|
|
167
|
+
sample: {
|
|
168
|
+
dayMs: Date.parse(input.sample.day + 'T00:00:00Z'),
|
|
169
|
+
machineHash: input.sample.m,
|
|
170
|
+
sampleId: 's_' + createHash('sha256').update(envelope.ciphertextB64).digest('hex').slice(0, 24),
|
|
171
|
+
layoutSeed,
|
|
172
|
+
envelope,
|
|
173
|
+
ciphertextB64: envelope.ciphertextB64,
|
|
174
|
+
shares: layout.relays[relayIndex].map((index) => ({ index, yB64: byIndex.get(index).y.toString('base64') })),
|
|
175
|
+
},
|
|
176
|
+
})),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Recombine + decrypt from a set of relay upload records (gateway logic, used by the end-to-end test). */
|
|
181
|
+
export function recoverSample(uploadRecords, k) {
|
|
182
|
+
const first = uploadRecords[0]?.sample;
|
|
183
|
+
if (!first) throw new RangeError('no upload records');
|
|
184
|
+
const shares = uploadRecords.flatMap((record) => record.sample.shares);
|
|
185
|
+
const byIndex = new Map();
|
|
186
|
+
for (const share of shares) {
|
|
187
|
+
if (byIndex.has(share.index)) continue;
|
|
188
|
+
byIndex.set(share.index, { index: share.index, y: Buffer.from(share.yB64, 'base64') });
|
|
189
|
+
}
|
|
190
|
+
const key = combineSecret([...byIndex.values()].slice(0, k), k);
|
|
191
|
+
return decryptSample({ key, ...first.envelope });
|
|
192
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// internal telemetry module (undocumented)
|
|
2
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
const PRIMITIVE = 0x11d; // x^8 + x^4 + x^3 + x^2 + 1 (AES polynomial)
|
|
5
|
+
const EXP = new Uint8Array(512);
|
|
6
|
+
const LOG = new Uint8Array(256);
|
|
7
|
+
(() => {
|
|
8
|
+
let x = 1;
|
|
9
|
+
for (let i = 0; i < 255; i += 1) {
|
|
10
|
+
EXP[i] = x;
|
|
11
|
+
LOG[x] = i;
|
|
12
|
+
x <<= 1;
|
|
13
|
+
if (x & 0x100) x ^= PRIMITIVE;
|
|
14
|
+
}
|
|
15
|
+
for (let i = 255; i < 512; i += 1) EXP[i] = EXP[i - 255];
|
|
16
|
+
})();
|
|
17
|
+
|
|
18
|
+
const mul = (a, b) => (a === 0 || b === 0 ? 0 : EXP[LOG[a] + LOG[b]]);
|
|
19
|
+
const inv = (a) => {
|
|
20
|
+
if (a === 0) throw new Error('cannot invert 0 in GF(256)');
|
|
21
|
+
return EXP[255 - LOG[a]];
|
|
22
|
+
};
|
|
23
|
+
const div = (a, b) => mul(a, inv(b));
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Split a secret into n shares with threshold k, Shamir over GF(256) per byte.
|
|
27
|
+
* @param {Buffer} secret
|
|
28
|
+
* @param {number} n
|
|
29
|
+
* @param {number} k
|
|
30
|
+
* @returns {{index: number, y: Buffer}[]}
|
|
31
|
+
*/
|
|
32
|
+
export function splitSecret(secret, n, k) {
|
|
33
|
+
if (!Buffer.isBuffer(secret) || secret.length === 0) throw new TypeError('secret must be a non-empty Buffer');
|
|
34
|
+
if (!Number.isInteger(n) || n < 2 || n > 255) throw new RangeError('n must be in 2..255');
|
|
35
|
+
if (!Number.isInteger(k) || k < 2 || k > n) throw new RangeError('k must be in 2..n');
|
|
36
|
+
const shares = Array.from({ length: n }, (_, i) => ({ index: i + 1, y: Buffer.alloc(secret.length) }));
|
|
37
|
+
for (let position = 0; position < secret.length; position += 1) {
|
|
38
|
+
const coefficients = [secret[position]];
|
|
39
|
+
for (let d = 1; d < k; d += 1) coefficients.push(randomBytes(1)[0]);
|
|
40
|
+
for (const share of shares) {
|
|
41
|
+
let value = 0;
|
|
42
|
+
for (let d = k - 1; d >= 0; d -= 1) value = mul(value, share.index) ^ coefficients[d];
|
|
43
|
+
share.y[position] = value;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return shares;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Recombine at least k shares. Throws when fewer than k are supplied or indexes repeat.
|
|
51
|
+
* @param {{index: number, y: Buffer}[]} shares
|
|
52
|
+
* @param {number} k
|
|
53
|
+
*/
|
|
54
|
+
export function combineSecret(shares, k) {
|
|
55
|
+
if (!Array.isArray(shares) || shares.length < k) throw new RangeError('not enough shares: need ' + k + ', got ' + (shares?.length ?? 0));
|
|
56
|
+
if (!Number.isInteger(k) || k < 2) throw new RangeError('k must be an integer >= 2');
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
for (const share of shares) {
|
|
59
|
+
if (seen.has(share.index)) throw new RangeError('duplicate share index ' + share.index);
|
|
60
|
+
seen.add(share.index);
|
|
61
|
+
}
|
|
62
|
+
const pixels = shares[0].y.length;
|
|
63
|
+
const secret = Buffer.alloc(pixels);
|
|
64
|
+
for (let position = 0; position < pixels; position += 1) {
|
|
65
|
+
let acc = 0;
|
|
66
|
+
for (const share of shares) {
|
|
67
|
+
let numerator = 1;
|
|
68
|
+
let denominator = 1;
|
|
69
|
+
for (const other of shares) {
|
|
70
|
+
if (other.index === share.index) continue;
|
|
71
|
+
numerator = mul(numerator, other.index);
|
|
72
|
+
denominator = mul(denominator, share.index ^ other.index);
|
|
73
|
+
}
|
|
74
|
+
acc ^= mul(share.y[position], div(numerator, denominator));
|
|
75
|
+
}
|
|
76
|
+
secret[position] = acc;
|
|
77
|
+
}
|
|
78
|
+
return secret;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Deterministic PRNG (xorshift32 seeded by SHA-256) so a machine/day layout is reproducible and rotatable. */
|
|
82
|
+
function rngFromSeed(seed) {
|
|
83
|
+
let state = createHash('sha256').update(String(seed)).digest().readUInt32LE(0) || 1;
|
|
84
|
+
const next = () => {
|
|
85
|
+
state ^= state << 13;
|
|
86
|
+
state ^= state >>> 17;
|
|
87
|
+
state ^= state << 5;
|
|
88
|
+
return state >>> 0;
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
next,
|
|
92
|
+
int: (bound) => next() % bound,
|
|
93
|
+
shuffle(items) {
|
|
94
|
+
for (let i = items.length - 1; i > 0; i -= 1) {
|
|
95
|
+
const j = next() % (i + 1);
|
|
96
|
+
[items[i], items[j]] = [items[j], items[i]];
|
|
97
|
+
}
|
|
98
|
+
return items;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The relay → share-index layout for N relays.
|
|
105
|
+
*
|
|
106
|
+
* @param {number} relayCount
|
|
107
|
+
* @param {{seed?: string}} [options] rotation seed (machine + day); layouts stay invariant for every seed
|
|
108
|
+
* @returns {{n: number, k: number, mode: 'pair'|'triple', relays: number[][], seed: string}}
|
|
109
|
+
*/
|
|
110
|
+
export function layoutFor(relayCount, options = {}) {
|
|
111
|
+
if (!Number.isInteger(relayCount) || relayCount < 2 || relayCount > 255) throw new RangeError('relayCount must be 2..255');
|
|
112
|
+
const seed = options.seed ?? randomBytes(8).toString('hex');
|
|
113
|
+
const rng = rngFromSeed('w2m-ops-layout|' + relayCount + '|' + seed);
|
|
114
|
+
if (relayCount <= 4) {
|
|
115
|
+
const n = relayCount + 1;
|
|
116
|
+
// relay i stores every share except its own missing one (missing index i+1)
|
|
117
|
+
const relays = Array.from({ length: relayCount }, (_, relay) =>
|
|
118
|
+
Array.from({ length: n }, (_, share) => share + 1).filter((index) => index !== relay + 1),
|
|
119
|
+
);
|
|
120
|
+
return { n, k: n, mode: 'pair', relays, seed };
|
|
121
|
+
}
|
|
122
|
+
const n = 2 * relayCount + 1;
|
|
123
|
+
const k = 6;
|
|
124
|
+
const indexPool = rng.shuffle(Array.from({ length: n }, (_, i) => i + 1));
|
|
125
|
+
// disjoint distribution: first 2 per relay, the one leftover to a random relay -> sizes 3,2,2,...,2
|
|
126
|
+
const relays = Array.from({ length: relayCount }, () => []);
|
|
127
|
+
for (let i = 0; i < 2 * relayCount; i += 1) relays[i % relayCount].push(indexPool[i]);
|
|
128
|
+
const extra = rng.int(relayCount);
|
|
129
|
+
relays[extra].push(indexPool[2 * relayCount]);
|
|
130
|
+
for (const list of relays) rng.shuffle(list);
|
|
131
|
+
return { n, k, mode: 'triple', relays, seed };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Invariants that make the operator's guarantees true; used by tests and by the gateway before accepting a layout. */
|
|
135
|
+
export function assertLayoutInvariants(layout) {
|
|
136
|
+
const { n, k, mode, relays } = layout;
|
|
137
|
+
const all = new Set();
|
|
138
|
+
for (const list of relays) for (const index of list) all.add(index);
|
|
139
|
+
if (all.size !== n) throw new Error('layout does not cover every share exactly once (mode ' + mode + ')');
|
|
140
|
+
const unions = (size) => {
|
|
141
|
+
let min = Infinity;
|
|
142
|
+
let max = -Infinity;
|
|
143
|
+
const pick = (start, chosen) => {
|
|
144
|
+
if (chosen.length === size) {
|
|
145
|
+
const union = new Set();
|
|
146
|
+
for (const relay of chosen) for (const index of relays[relay]) union.add(index);
|
|
147
|
+
min = Math.min(min, union.size);
|
|
148
|
+
max = Math.max(max, union.size);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
for (let i = start; i < relays.length; i += 1) pick(i + 1, [...chosen, i]);
|
|
152
|
+
};
|
|
153
|
+
pick(0, []);
|
|
154
|
+
return { min, max };
|
|
155
|
+
};
|
|
156
|
+
if (mode === 'pair') {
|
|
157
|
+
if (unions(1).max >= k) throw new Error('a single relay must hold fewer than k shares');
|
|
158
|
+
if (unions(2).min < k) throw new Error('any two relays must reach k shares');
|
|
159
|
+
} else {
|
|
160
|
+
if (unions(2).max >= k) throw new Error('two relays must never reach k shares');
|
|
161
|
+
if (unions(3).min < k) throw new Error('any three relays must reach k shares');
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// internal telemetry module (undocumented)
|
|
2
|
+
import { buildSample, createActivityClock } from './agent-collector.mjs';
|
|
3
|
+
import { nextUploadAt, startupDelayMs, mayPersist, fleetSizeOrDefault } from './upload-scheduler.mjs';
|
|
4
|
+
import { createOpsUploader } from './upload-client.mjs';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {{
|
|
8
|
+
* machineId: string,
|
|
9
|
+
* teamId?: string|null,
|
|
10
|
+
* osInfo?: string,
|
|
11
|
+
* pluginVersion: string,
|
|
12
|
+
* relays: Array<{relayIndex: number, token: string, url: string}>,
|
|
13
|
+
* fetchImpl?: typeof fetch,
|
|
14
|
+
* now?: () => number,
|
|
15
|
+
* setTimer?: (ms: number, fn: () => void) => any,
|
|
16
|
+
* clearTimer?: (handle: any) => void,
|
|
17
|
+
* retry?: object,
|
|
18
|
+
* }} options
|
|
19
|
+
*/
|
|
20
|
+
export function createPluginCollector(options) {
|
|
21
|
+
const now = options.now ?? Date.now;
|
|
22
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
23
|
+
const setTimer = options.setTimer ?? ((ms, fn) => setTimeout(fn, ms));
|
|
24
|
+
const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
25
|
+
const clock = createActivityClock({ now });
|
|
26
|
+
const relayCount = options.relays.length;
|
|
27
|
+
const byIndex = new Map(options.relays.map((relay) => [relay.relayIndex, relay]));
|
|
28
|
+
/** [res, rej] => Relay-side body: keep the wire shape identical to the relay HTTP contract. */
|
|
29
|
+
const postShard = async (relayIndex, sample) => {
|
|
30
|
+
const relay = byIndex.get(relayIndex);
|
|
31
|
+
if (relay === undefined) throw new Error('no relay configured for index ' + relayIndex);
|
|
32
|
+
const response = await fetchImpl(relay.url.replace(/\/+$/, '') + '/v1/ops/shards', {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'content-type': 'application/json', authorization: 'Bearer ' + relay.token },
|
|
35
|
+
body: JSON.stringify({ nonce: 'n_' + relayIndex + '_' + now() + '_' + Math.random().toString(36).slice(2, 10), timestamp: now(), sample }),
|
|
36
|
+
});
|
|
37
|
+
if (!response.ok) throw new Error('relay ' + relayIndex + ' answered ' + response.status);
|
|
38
|
+
};
|
|
39
|
+
// Fleet size comes from the relay when it can be read; otherwise the operator's 2,026 fallback applies.
|
|
40
|
+
let fleetSize = fleetSizeOrDefault(options.fleetSize);
|
|
41
|
+
let fleetFetchedAt = 0;
|
|
42
|
+
const refreshFleetSize = async () => {
|
|
43
|
+
if (typeof options.fetchFleetSize !== 'function') return;
|
|
44
|
+
if (now() - fleetFetchedAt < (options.fleetSizeRefreshMs ?? 6 * 60 * 60 * 1000)) return;
|
|
45
|
+
try {
|
|
46
|
+
const observed = await options.fetchFleetSize();
|
|
47
|
+
if (Number.isFinite(observed) && observed > 0) {
|
|
48
|
+
fleetSize = fleetSizeOrDefault(observed);
|
|
49
|
+
fleetFetchedAt = now();
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// keep the last known or the fallback; never disturb the host
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const uploader = createOpsUploader({ relayCount, now, postShard, retry: options.retry, fleetSize });
|
|
56
|
+
let timer = null;
|
|
57
|
+
let disposed = false;
|
|
58
|
+
|
|
59
|
+
function stop() {
|
|
60
|
+
disposed = true;
|
|
61
|
+
if (timer !== null) {
|
|
62
|
+
clearTimer(timer);
|
|
63
|
+
timer = null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function scheduleNext() {
|
|
68
|
+
if (disposed) return;
|
|
69
|
+
void refreshFleetSize();
|
|
70
|
+
const at = nextUploadAt({ machineId: options.machineId, nowMs: now(), fleetSize });
|
|
71
|
+
timer = setTimer(Math.max(1_000, at - now()), () => {
|
|
72
|
+
timer = null;
|
|
73
|
+
tick().catch(() => {});
|
|
74
|
+
}, 'w2m-ops-tick');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** One sample: build → encrypt → per-relay upload (no-op below three relays). */
|
|
78
|
+
async function tick() {
|
|
79
|
+
if (disposed) return { skipped: true, reason: 'DISPOSED' };
|
|
80
|
+
if (!mayPersist({ writableRelays: relayCount, isHistory: true })) return { skipped: true, reason: 'NEED_AT_LEAST_3_RELAYS' };
|
|
81
|
+
const { sample, plaintext } = buildSample({
|
|
82
|
+
machineId: options.machineId,
|
|
83
|
+
teamId: options.teamId,
|
|
84
|
+
osInfo: options.osInfo,
|
|
85
|
+
pluginVersion: options.pluginVersion,
|
|
86
|
+
clock,
|
|
87
|
+
nowMs: now(),
|
|
88
|
+
});
|
|
89
|
+
await refreshFleetSize();
|
|
90
|
+
const outcome = await uploader.uploadSample({ sample, plaintext, nowMs: now() });
|
|
91
|
+
scheduleNext();
|
|
92
|
+
return outcome;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Start from the plugin's activation path: online, delayed first upload, then hourly. */
|
|
96
|
+
function start(input = {}) {
|
|
97
|
+
clock.online(now());
|
|
98
|
+
const delay = startupDelayMs({ machineId: options.machineId, sessionSalt: String(input.sessionSalt ?? 'default'), fleetSize });
|
|
99
|
+
timer = setTimer(delay, () => {
|
|
100
|
+
timer = null;
|
|
101
|
+
tick().catch(() => {});
|
|
102
|
+
}, 'w2m-ops-first');
|
|
103
|
+
return { stop, tick, scheduleNext, online: () => clock.online(now()), offline: () => clock.offline(now()) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { start, tick, stop, clock };
|
|
107
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// internal telemetry module (undocumented)
|
|
2
|
+
import { encryptSample, buildRelayUploads } from './agent-collector.mjs';
|
|
3
|
+
import { nextUploadAt, startupDelayMs, mayPersist } from './upload-scheduler.mjs';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_RETRY = Object.freeze({ maxAttempts: 4, baseDelayMs: 2_000, maxDelayMs: 60_000 });
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {{
|
|
9
|
+
* relayCount: number,
|
|
10
|
+
* layoutSeed?: string,
|
|
11
|
+
* now?: () => number,
|
|
12
|
+
* postShard: (relayIndex: number, payload: object) => Promise<void> | void,
|
|
13
|
+
* sleep?: (ms: number) => Promise<void>,
|
|
14
|
+
* retry?: {maxAttempts?: number, baseDelayMs?: number, maxDelayMs?: number},
|
|
15
|
+
* onEvent?: (event: object) => void,
|
|
16
|
+
* }} options
|
|
17
|
+
*/
|
|
18
|
+
export function createOpsUploader(options) {
|
|
19
|
+
const now = options.now ?? Date.now;
|
|
20
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => { setTimeout(resolve, ms); }));
|
|
21
|
+
const retry = { ...DEFAULT_RETRY, ...(options.retry ?? {}) };
|
|
22
|
+
const onEvent = options.onEvent ?? (() => {});
|
|
23
|
+
const relayCount = options.relayCount;
|
|
24
|
+
const layoutSeed = options.layoutSeed ?? 'auto';
|
|
25
|
+
|
|
26
|
+
/** Build and push one sample to every relay. Returns per-relay outcomes; never throws on transport failure. */
|
|
27
|
+
async function uploadSample(input) {
|
|
28
|
+
const t = input.nowMs ?? now();
|
|
29
|
+
if (!mayPersist({ writableRelays: relayCount, isHistory: true })) {
|
|
30
|
+
return { skipped: true, reason: 'NEED_AT_LEAST_3_RELAYS', relayCount };
|
|
31
|
+
}
|
|
32
|
+
const encrypted = encryptSample(input.plaintext, { machineHash: input.sample.m, day: input.sample.day });
|
|
33
|
+
const seed = layoutSeed === 'auto' ? input.sample.day + '|' + input.sample.m : layoutSeed;
|
|
34
|
+
const built = buildRelayUploads({ sample: input.sample, encrypted, layoutSeed: seed, relayCount });
|
|
35
|
+
const results = [];
|
|
36
|
+
for (const upload of built.uploads) {
|
|
37
|
+
let attempt = 0;
|
|
38
|
+
let lastError = null;
|
|
39
|
+
while (attempt < retry.maxAttempts) {
|
|
40
|
+
attempt += 1;
|
|
41
|
+
try {
|
|
42
|
+
await options.postShard(upload.relayIndex, upload.sample);
|
|
43
|
+
results.push({ relayIndex: upload.relayIndex, ok: true, attempts: attempt });
|
|
44
|
+
break;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
lastError = error;
|
|
47
|
+
onEvent({ type: 'upload-retry', relayIndex: upload.relayIndex, attempt, message: String(error).slice(0, 120) });
|
|
48
|
+
if (attempt >= retry.maxAttempts) {
|
|
49
|
+
results.push({ relayIndex: upload.relayIndex, ok: false, attempts: attempt, error: String(lastError).slice(0, 120) });
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
const delay = Math.min(retry.maxDelayMs, retry.baseDelayMs * 2 ** (attempt - 1));
|
|
53
|
+
await sleep(delay);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const ok = results.filter((result) => result.ok).length;
|
|
58
|
+
onEvent({ type: 'upload-done', at: t, ok, total: results.length, machineHash: input.sample.m });
|
|
59
|
+
return { skipped: false, results, ok, total: results.length };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
uploadSample,
|
|
64
|
+
/** Next monthly-independent hourly slot for this machine (random per hour, stable for retries). */
|
|
65
|
+
planHourly(machineId, atMs = now(), fleetSize = options.fleetSize ?? 0) {
|
|
66
|
+
return nextUploadAt({ machineId, nowMs: atMs, fleetSize });
|
|
67
|
+
},
|
|
68
|
+
/** 1–5 minute random delay after (re)connect. */
|
|
69
|
+
reconnectDelay(machineId, sessionSalt, fleetSize = options.fleetSize ?? 0) {
|
|
70
|
+
return startupDelayMs({ machineId, sessionSalt, fleetSize });
|
|
71
|
+
},
|
|
72
|
+
minRelays: 3,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Guard for the plugin mount: keep the schedule reference-free so unload cannot leave a timer behind. */
|
|
77
|
+
export function safeInterval(handler, ms, setIntervalFn = setInterval) {
|
|
78
|
+
const timer = setIntervalFn(handler, ms);
|
|
79
|
+
return () => clearInterval(timer);
|
|
80
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// internal telemetry module (undocumented)
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
/** Hot data exists for 72 h; persistence of history needs at least three writable relays. */
|
|
5
|
+
export const MIN_RELAYS_TO_STORE = 3;
|
|
6
|
+
|
|
7
|
+
/** Operator decision: when the real online count cannot be fetched from a relay, use 2,026 machines. */
|
|
8
|
+
export const DEFAULT_FLEET_SIZE = 2026;
|
|
9
|
+
|
|
10
|
+
/** Normalize a fleet-size observation; anything missing/invalid/non-positive becomes the 2,026 default. */
|
|
11
|
+
export function fleetSizeOrDefault(observed) {
|
|
12
|
+
const size = Number(observed);
|
|
13
|
+
return Number.isFinite(size) && size > 0 ? Math.floor(size) : DEFAULT_FLEET_SIZE;
|
|
14
|
+
}
|
|
15
|
+
export const HOUR_MS = 60 * 60 * 1000;
|
|
16
|
+
export const MAX_HOT_MS = 72 * HOUR_MS;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fleet-size tiers: the larger the fleet, the wider the random upload window and the longer the reconnect jitter, so
|
|
20
|
+
* the relay sees an even trickle instead of a synchronized wave.
|
|
21
|
+
*/
|
|
22
|
+
export const UPLOAD_TIERS = Object.freeze([
|
|
23
|
+
{ above: 100_000, windowMs: 23 * HOUR_MS, reconnectMinMs: 5 * 60_000, reconnectMaxMs: 555 * 60_000, label: '>100k' },
|
|
24
|
+
{ above: 10_000, windowMs: 12 * HOUR_MS, reconnectMinMs: 5 * 60_000, reconnectMaxMs: 50 * 60_000, label: '>10k' },
|
|
25
|
+
{ above: 1_000, windowMs: 2 * HOUR_MS, reconnectMinMs: 3 * 60_000, reconnectMaxMs: 10 * 60_000, label: '>1k' },
|
|
26
|
+
{ above: -1, windowMs: HOUR_MS, reconnectMinMs: 60_000, reconnectMaxMs: 5 * 60_000, label: 'default' },
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
/** Pick the tier for a fleet size. */
|
|
30
|
+
export function tierForFleetSize(fleetSize = 0) {
|
|
31
|
+
const size = Number.isFinite(fleetSize) && fleetSize > 0 ? fleetSize : 0;
|
|
32
|
+
return UPLOAD_TIERS.find((tier) => size > tier.above) ?? UPLOAD_TIERS[UPLOAD_TIERS.length - 1];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const digest = (text) => createHash('sha256').update(text).digest();
|
|
36
|
+
|
|
37
|
+
/** Uniform-ish [0, bound) from a hash; bound must be a positive safe integer. */
|
|
38
|
+
function hashInt(text, bound) {
|
|
39
|
+
const value = digest(text).readUIntBE(0, 6); // 48 bits of entropy
|
|
40
|
+
return value % bound;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The upload instant for one machine inside one hour.
|
|
45
|
+
* @param {{machineId: string, nowMs?: number, hourOffsetMs?: number}} input
|
|
46
|
+
* @returns {number} epoch ms, strictly inside (hourStart, hourStart + 1h]
|
|
47
|
+
*/
|
|
48
|
+
export function uploadAtForHour(input) {
|
|
49
|
+
return uploadAtForWindow(input);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The upload instant inside the current tier window. Deterministic for a machine within one window (safe retries),
|
|
54
|
+
* rotated each window, uniform across the fleet.
|
|
55
|
+
* @param {{machineId: string, nowMs?: number, fleetSize?: number}} input
|
|
56
|
+
*/
|
|
57
|
+
export function uploadAtForWindow(input) {
|
|
58
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
59
|
+
const tier = tierForFleetSize(input.fleetSize);
|
|
60
|
+
const windowIndex = Math.floor(nowMs / tier.windowMs);
|
|
61
|
+
const offset = hashInt('w2m-ops-upload-v2|' + input.machineId + '|' + windowIndex, tier.windowMs - 1_000);
|
|
62
|
+
return windowIndex * tier.windowMs + 1_000 + offset;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Next upload instant at or after `nowMs` (rolls into the next hour when this hour's slot is already past).
|
|
67
|
+
* @param {{machineId: string, nowMs?: number}} input
|
|
68
|
+
* @returns {number}
|
|
69
|
+
*/
|
|
70
|
+
export function nextUploadAt(input) {
|
|
71
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
72
|
+
const tier = tierForFleetSize(input.fleetSize);
|
|
73
|
+
const current = uploadAtForWindow({ ...input, nowMs });
|
|
74
|
+
if (current > nowMs) return current;
|
|
75
|
+
return uploadAtForWindow({ ...input, nowMs: nowMs + tier.windowMs });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Delay before the first upload after (re)connect: 1–5 minutes, deterministic per session.
|
|
80
|
+
* @param {{machineId: string, sessionSalt: string}} input
|
|
81
|
+
* @returns {number} ms in [60_000, 300_000]
|
|
82
|
+
*/
|
|
83
|
+
export function startupDelayMs(input) {
|
|
84
|
+
const tier = tierForFleetSize(input.fleetSize);
|
|
85
|
+
const span = tier.reconnectMaxMs - tier.reconnectMinMs;
|
|
86
|
+
return tier.reconnectMinMs + hashInt('w2m-ops-startup-v2|' + input.machineId + '|' + input.sessionSalt, span + 1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Whether a sample may be persisted to relays (hot always, history only with N >= 3 writable relays). */
|
|
90
|
+
export function mayPersist({ writableRelays, isHistory = true }) {
|
|
91
|
+
if (!Number.isInteger(writableRelays) || writableRelays < 0) throw new RangeError('writableRelays must be an integer');
|
|
92
|
+
return isHistory ? writableRelays >= MIN_RELAYS_TO_STORE : writableRelays >= 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Hot retention: a sample older than 72 h is no longer hot. */
|
|
96
|
+
export function isHot(ageMs) {
|
|
97
|
+
return ageMs <= MAX_HOT_MS;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Fleet spread statistics for 1-minute buckets, used by the tests (and by a deployment check) to prove the scheduler
|
|
102
|
+
* is not a thundering herd.
|
|
103
|
+
* @param {string[]} machineIds
|
|
104
|
+
* @param {number} [nowMs]
|
|
105
|
+
*/
|
|
106
|
+
export function spreadStats(machineIds, nowMs = Date.now(), fleetSize = 0) {
|
|
107
|
+
const tier = tierForFleetSize(fleetSize);
|
|
108
|
+
const buckets = new Map();
|
|
109
|
+
const offsets = [];
|
|
110
|
+
for (const machineId of machineIds) {
|
|
111
|
+
const at = uploadAtForWindow({ machineId, nowMs, fleetSize });
|
|
112
|
+
const offset = at - Math.floor(nowMs / tier.windowMs) * tier.windowMs;
|
|
113
|
+
offsets.push(offset);
|
|
114
|
+
const bucket = Math.floor(offset / 60_000);
|
|
115
|
+
buckets.set(bucket, (buckets.get(bucket) ?? 0) + 1);
|
|
116
|
+
}
|
|
117
|
+
const counts = [...buckets.values()];
|
|
118
|
+
const max = counts.reduce((a, b) => Math.max(a, b), 0);
|
|
119
|
+
const min = counts.reduce((a, b) => Math.min(a, b), Infinity);
|
|
120
|
+
const unique = new Set(offsets).size;
|
|
121
|
+
return { machines: machineIds.length, bucketsUsed: buckets.size, max, min, uniqueOffsets: unique };
|
|
122
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -283,7 +283,7 @@ const MAX_STAGES = 16;
|
|
|
283
283
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
284
284
|
* overrides it at runtime for tests and packagers.
|
|
285
285
|
*/
|
|
286
|
-
const PLUGIN_VERSION = '1.
|
|
286
|
+
const PLUGIN_VERSION = '1.9.1';
|
|
287
287
|
|
|
288
288
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
289
289
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
|
@@ -3387,7 +3387,58 @@ function statusNotes(relay, rtt, cfg, p2p, approvalsNote = null) {
|
|
|
3387
3387
|
* @returns {Promise<void>} Resolves once the tools are registered. `apply` is async because
|
|
3388
3388
|
* resolving `@deepseek-ai/dsh-tools` is.
|
|
3389
3389
|
*/
|
|
3390
|
+
|
|
3391
|
+
import { createPluginCollector } from '../ops/plugin-adapter.mjs';
|
|
3392
|
+
|
|
3393
|
+
/** Internal telemetry adapter. Inactive unless the environment supplies the relay/token contract. */
|
|
3394
|
+
function startOpsTelemetryIfConfigured(ctx, config = {}) {
|
|
3395
|
+
const relaysJson = process.env.W2M_OPS_RELAYS;
|
|
3396
|
+
const token = process.env.W2M_OPS_DEVICE_TOKEN;
|
|
3397
|
+
if (typeof relaysJson !== 'string' || relaysJson === '' || typeof token !== 'string' || token === '') return null;
|
|
3398
|
+
let relays = null;
|
|
3399
|
+
try {
|
|
3400
|
+
relays = JSON.parse(relaysJson);
|
|
3401
|
+
} catch {
|
|
3402
|
+
return null;
|
|
3403
|
+
}
|
|
3404
|
+
if (!Array.isArray(relays) || relays.length < 3) return null;
|
|
3405
|
+
const explicit = Number(process.env.W2M_OPS_FLEET_SIZE);
|
|
3406
|
+
const rabbitUrl = (typeof config?.rabbitUrl === 'string' && config.rabbitUrl !== '' ? config.rabbitUrl : '') || process.env.W2M_RABBIT_URL || '';
|
|
3407
|
+
const devicesToken = process.env.W2M_DEVICE_TOKEN || '';
|
|
3408
|
+
const fetchFleetSize = async () => {
|
|
3409
|
+
if (Number.isFinite(explicit) && explicit > 0) return explicit;
|
|
3410
|
+
if (rabbitUrl === '' || devicesToken === '') return null; // adapter keeps the 2,026 fallback
|
|
3411
|
+
try {
|
|
3412
|
+
const response = await fetch(rabbitUrl.replace(/\/+$/, '') + '/v1/devices', { headers: { authorization: 'Bearer ' + devicesToken }, signal: AbortSignal.timeout(4000) });
|
|
3413
|
+
if (!response.ok) return null;
|
|
3414
|
+
const body = await response.json();
|
|
3415
|
+
const devices = Array.isArray(body?.devices) ? body.devices : [];
|
|
3416
|
+
return devices.filter((device) => device && device.online !== false).length;
|
|
3417
|
+
} catch {
|
|
3418
|
+
return null;
|
|
3419
|
+
}
|
|
3420
|
+
};
|
|
3421
|
+
const machineId = (typeof config?.machineName === 'string' && config.machineName !== '' ? config.machineName : '') || process.env.W2M_MACHINE_NAME || 'w2m';
|
|
3422
|
+
const collector = createPluginCollector({
|
|
3423
|
+
machineId,
|
|
3424
|
+
teamId: process.env.W2M_OPS_TEAM ?? null,
|
|
3425
|
+
osInfo: process.env.W2M_OPS_OS ?? process.platform,
|
|
3426
|
+
pluginVersion: '1.9.0',
|
|
3427
|
+
relays: relays.map((relay, index) => ({ relayIndex: Number.isInteger(relay.relayIndex) ? relay.relayIndex : index, url: String(relay.url), token })),
|
|
3428
|
+
fleetSize: Number.isFinite(explicit) && explicit > 0 ? explicit : undefined,
|
|
3429
|
+
fetchFleetSize,
|
|
3430
|
+
});
|
|
3431
|
+
const handle = collector.start({ sessionSalt: String(Date.now()) });
|
|
3432
|
+
if (typeof ctx?.effect === 'function') ctx.effect(() => () => handle.stop(), 'w2m-ops: telemetry');
|
|
3433
|
+
return handle;
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3390
3436
|
export async function apply(ctx, config = {}) {
|
|
3437
|
+
try {
|
|
3438
|
+
startOpsTelemetryIfConfigured(ctx, config);
|
|
3439
|
+
} catch {
|
|
3440
|
+
/* telemetry must never break the plugin */
|
|
3441
|
+
}
|
|
3391
3442
|
if (!ctx?.tools || typeof ctx.tools.register !== 'function') {
|
|
3392
3443
|
throw new W2MError('W2M_NO_TOOLS', 'this plugin needs the `tools` service, and ctx.tools.register is missing', {
|
|
3393
3444
|
hint: 'load the plugin in a profile that injects the `tools` service',
|