@venturewild/workspace 0.6.3 → 0.6.5
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 -21
- package/README.md +112 -112
- package/package.json +85 -85
- package/server/bin/wild-workspace.mjs +1096 -1096
- package/server/src/account.mjs +114 -114
- package/server/src/agent-login.mjs +146 -146
- package/server/src/agent-readiness.mjs +200 -200
- package/server/src/agent.mjs +468 -468
- package/server/src/bazaar/core.mjs +974 -974
- package/server/src/bazaar/index.mjs +88 -88
- package/server/src/bazaar/mcp-server.mjs +429 -429
- package/server/src/bazaar/mock-tickup.mjs +97 -97
- package/server/src/bazaar/preview-server.mjs +95 -95
- package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
- package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
- package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
- package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
- package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
- package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
- package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
- package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +40 -40
- package/server/src/canvas/core.mjs +446 -446
- package/server/src/canvas/index.mjs +42 -42
- package/server/src/canvas/mcp-server.mjs +253 -253
- package/server/src/canvas-rails.mjs +108 -108
- package/server/src/config.mjs +404 -404
- package/server/src/daemon-bin.mjs +110 -110
- package/server/src/daemon-supervisor.mjs +285 -285
- package/server/src/doctor.mjs +375 -375
- package/server/src/inbox.mjs +86 -86
- package/server/src/index.mjs +3332 -3332
- package/server/src/listings-rails.mjs +156 -156
- package/server/src/logpaths.mjs +98 -98
- package/server/src/observability.mjs +45 -45
- package/server/src/operator.mjs +92 -92
- package/server/src/pairing.mjs +137 -137
- package/server/src/service.mjs +515 -515
- package/server/src/session-reporter.mjs +201 -201
- package/server/src/settings.mjs +145 -145
- package/server/src/share.mjs +182 -182
- package/server/src/skills.mjs +213 -213
- package/server/src/supervisor.mjs +647 -647
- package/server/src/support-consent.mjs +133 -133
- package/server/src/sync.mjs +248 -248
- package/server/src/transcript.mjs +121 -121
- package/server/src/turn-mcp.mjs +46 -46
- package/server/src/usage.mjs +405 -405
- package/server/src/workspace-registry.mjs +295 -295
- package/server/src/workspaces.mjs +145 -145
- package/web/dist/assets/index-nEl9swiQ.js +131 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-DVflHhYJ.js +0 -131
|
@@ -1,201 +1,201 @@
|
|
|
1
|
-
// SessionReporter — the proactive, consented "is this user okay?" feed.
|
|
2
|
-
//
|
|
3
|
-
// WHY: the get-in path is self-serve, but if a real user gets stuck or their
|
|
4
|
-
// install breaks, we were blind unless they ran `operator enable` and messaged
|
|
5
|
-
// us — which makes us a bottleneck (docs/user-experience.md §5). This forwards a
|
|
6
|
-
// LIVE, REDACTED stream of session events + install health to bmo-sync, keyed by
|
|
7
|
-
// account, established at first load — so a stuck/broken user is never invisible
|
|
8
|
-
// and never has to ask.
|
|
9
|
-
//
|
|
10
|
-
// PRIVACY — the load-bearing boundary: this feed carries WHAT happened (a turn
|
|
11
|
-
// ran, tool X fired, an error, token/cost), NEVER the words. Chat text, tool
|
|
12
|
-
// inputs, file contents, and paths are reduced to lengths/counts before anything
|
|
13
|
-
// leaves the machine. Conversation *content* is a separate, separately-consented
|
|
14
|
-
// channel (transcript.mjs). `redactEvent` is an ALLOWLIST projection — any event
|
|
15
|
-
// type we don't explicitly model forwards only `{type}`, so a new event can
|
|
16
|
-
// never leak by default. The matching test asserts a secret typed into a chat
|
|
17
|
-
// turn never appears in the payload.
|
|
18
|
-
//
|
|
19
|
-
// Modeled on error-reporter.mjs (fire-and-forget, rate-limited, disable-able).
|
|
20
|
-
// Gated by BOTH consent (user toggle) AND the shared WILD_WORKSPACE_NO_TELEMETRY
|
|
21
|
-
// kill switch; inert without an accountToken (can't key it) or on a localhost
|
|
22
|
-
// bmo-sync URL (dev).
|
|
23
|
-
|
|
24
|
-
import os from 'node:os';
|
|
25
|
-
import { APP_VERSION } from './config.mjs';
|
|
26
|
-
|
|
27
|
-
function sanitizeUsage(u) {
|
|
28
|
-
if (!u || typeof u !== 'object') return null;
|
|
29
|
-
const out = {
|
|
30
|
-
input_tokens: Number(u.input_tokens) || 0,
|
|
31
|
-
output_tokens: Number(u.output_tokens) || 0,
|
|
32
|
-
};
|
|
33
|
-
if (typeof u.cost_usd === 'number') out.cost_usd = u.cost_usd;
|
|
34
|
-
return out;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Project one ActivityBus event to a SAFE shape. Allowlist by type; anything not
|
|
39
|
-
* listed forwards only {type, ts, id}. NEVER returns chat text, tool inputs,
|
|
40
|
-
* file paths, or file contents.
|
|
41
|
-
*/
|
|
42
|
-
export function redactEvent(ev) {
|
|
43
|
-
if (!ev || typeof ev !== 'object') return null;
|
|
44
|
-
const base = { type: ev.type, ts: ev.ts, id: ev.id };
|
|
45
|
-
if (ev.messageId) base.messageId = ev.messageId;
|
|
46
|
-
switch (ev.type) {
|
|
47
|
-
case 'chat-user':
|
|
48
|
-
// The user's prompt — length only, never the words.
|
|
49
|
-
return { ...base, textLen: typeof ev.text === 'string' ? ev.text.length : 0 };
|
|
50
|
-
case 'chat-stream': {
|
|
51
|
-
const c = ev.chunk || {};
|
|
52
|
-
const safe = { ...base, chunkType: c.type };
|
|
53
|
-
if (c.type === 'text' && typeof c.text === 'string') safe.textLen = c.text.length;
|
|
54
|
-
// tool NAME is safe ("Edit", "Bash"); the tool INPUT is not — never forward it.
|
|
55
|
-
if (c.type === 'tool-use') safe.tool = typeof c.name === 'string' ? c.name : c.tool || null;
|
|
56
|
-
if (c.type === 'usage' && c.usage) safe.usage = sanitizeUsage(c.usage);
|
|
57
|
-
if (c.type === 'error') safe.hasError = true; // the flag, not the message
|
|
58
|
-
return safe;
|
|
59
|
-
}
|
|
60
|
-
case 'usage':
|
|
61
|
-
return { ...base, usage: sanitizeUsage(ev.usage) };
|
|
62
|
-
case 'chat-end':
|
|
63
|
-
return { ...base, code: ev.code };
|
|
64
|
-
case 'identity-changed':
|
|
65
|
-
return { ...base, tone: ev.tone || null }; // tone only; drop the agent's name
|
|
66
|
-
case 'onboarded':
|
|
67
|
-
return { ...base, at: ev.at || null };
|
|
68
|
-
case 'agent-changed':
|
|
69
|
-
return { ...base, agentId: ev.agentId || null };
|
|
70
|
-
case 'daemon-status':
|
|
71
|
-
return { ...base, status: ev.status || null };
|
|
72
|
-
case 'operator-action':
|
|
73
|
-
return { ...base, action: ev.action || null }; // action verb, not its detail
|
|
74
|
-
case 'inbox-change':
|
|
75
|
-
return { ...base, count: Array.isArray(ev.snapshot) ? ev.snapshot.length : undefined };
|
|
76
|
-
case 'presence-join':
|
|
77
|
-
case 'presence-leave':
|
|
78
|
-
case 'presence-focus':
|
|
79
|
-
// sessionId + role are safe; `focus` can be a file path → dropped.
|
|
80
|
-
return { ...base, sessionId: ev.sessionId, role: ev.role };
|
|
81
|
-
default:
|
|
82
|
-
// Unknown event → minimal envelope only. Privacy fails CLOSED.
|
|
83
|
-
return { type: ev.type, ts: ev.ts, id: ev.id };
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const FLUSH_INTERVAL_MS = 15_000; // batch window
|
|
88
|
-
const MAX_BATCH = 50; // flush early once this many buffer
|
|
89
|
-
const MAX_BUFFER = 500; // hard cap — drop oldest beyond this
|
|
90
|
-
|
|
91
|
-
export class SessionReporter {
|
|
92
|
-
constructor({
|
|
93
|
-
bmoSyncUrl,
|
|
94
|
-
accountToken,
|
|
95
|
-
slug = null,
|
|
96
|
-
workspaceId = 'workspace',
|
|
97
|
-
sessionId = null,
|
|
98
|
-
enabled = true,
|
|
99
|
-
endpointPath = '/api/telemetry',
|
|
100
|
-
flushIntervalMs = FLUSH_INTERVAL_MS,
|
|
101
|
-
maxBatch = MAX_BATCH,
|
|
102
|
-
fetchImpl = (...a) => globalThis.fetch(...a),
|
|
103
|
-
nowImpl = () => Date.now(),
|
|
104
|
-
} = {}) {
|
|
105
|
-
this.bmoSyncUrl = bmoSyncUrl ? bmoSyncUrl.replace(/\/$/, '') : null;
|
|
106
|
-
this.accountToken = accountToken || null;
|
|
107
|
-
this.slug = slug;
|
|
108
|
-
this.workspaceId = workspaceId;
|
|
109
|
-
this.sessionId = sessionId;
|
|
110
|
-
this.endpointPath = endpointPath;
|
|
111
|
-
this.flushIntervalMs = flushIntervalMs;
|
|
112
|
-
this.maxBatch = maxBatch;
|
|
113
|
-
this.fetchImpl = fetchImpl;
|
|
114
|
-
this.nowImpl = nowImpl;
|
|
115
|
-
this.buffer = [];
|
|
116
|
-
this.timer = null;
|
|
117
|
-
// Inert without a token, without a server, or on localhost (dev).
|
|
118
|
-
this._capable =
|
|
119
|
-
Boolean(this.accountToken) &&
|
|
120
|
-
Boolean(this.bmoSyncUrl) &&
|
|
121
|
-
!this.bmoSyncUrl.startsWith('http://127') &&
|
|
122
|
-
!this.bmoSyncUrl.startsWith('http://localhost');
|
|
123
|
-
this.enabled = enabled !== false && this._capable;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Live consent toggle — no restart needed when the user flips observability. */
|
|
127
|
-
setEnabled(on) {
|
|
128
|
-
this.enabled = Boolean(on) && this._capable;
|
|
129
|
-
if (!this.enabled) this.buffer = [];
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Feed one ActivityBus event. Redacts + buffers; flushes on size. No-op when off. */
|
|
133
|
-
ingest(ev) {
|
|
134
|
-
if (!this.enabled) return;
|
|
135
|
-
const safe = redactEvent(ev);
|
|
136
|
-
if (!safe) return;
|
|
137
|
-
this.buffer.push(safe);
|
|
138
|
-
if (this.buffer.length > MAX_BUFFER) this.buffer.splice(0, this.buffer.length - MAX_BUFFER);
|
|
139
|
-
if (this.buffer.length >= this.maxBatch) this.flush();
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** POST the install-health snapshot alongside events (called by the supervisor path too). */
|
|
143
|
-
envelope(events, extra = {}) {
|
|
144
|
-
return {
|
|
145
|
-
account_token: this.accountToken,
|
|
146
|
-
slug: this.slug,
|
|
147
|
-
workspace_id: this.workspaceId,
|
|
148
|
-
session_id: this.sessionId,
|
|
149
|
-
app_version: APP_VERSION,
|
|
150
|
-
os: `${os.platform()}-${os.arch()}`,
|
|
151
|
-
sent_at: Math.floor(this.nowImpl() / 1000),
|
|
152
|
-
events,
|
|
153
|
-
...extra,
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** Fire-and-forget flush of the current buffer. Never throws. */
|
|
158
|
-
flush() {
|
|
159
|
-
if (!this.enabled || !this.buffer.length) return;
|
|
160
|
-
const events = this.buffer;
|
|
161
|
-
this.buffer = [];
|
|
162
|
-
this._post(this.envelope(events));
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
_post(body) {
|
|
166
|
-
const url = `${this.bmoSyncUrl}${this.endpointPath}`;
|
|
167
|
-
const ctrl = new AbortController();
|
|
168
|
-
const timer = setTimeout(() => ctrl.abort(), 5000);
|
|
169
|
-
if (timer.unref) timer.unref();
|
|
170
|
-
let p;
|
|
171
|
-
try {
|
|
172
|
-
// Call synchronously so the request is observable without awaiting a tick.
|
|
173
|
-
p = this.fetchImpl(url, {
|
|
174
|
-
method: 'POST',
|
|
175
|
-
headers: { 'content-type': 'application/json' },
|
|
176
|
-
body: JSON.stringify(body),
|
|
177
|
-
signal: ctrl.signal,
|
|
178
|
-
});
|
|
179
|
-
} catch {
|
|
180
|
-
clearTimeout(timer);
|
|
181
|
-
return; // telemetry must never break the user's path
|
|
182
|
-
}
|
|
183
|
-
Promise.resolve(p)
|
|
184
|
-
.catch(() => {})
|
|
185
|
-
.finally(() => clearTimeout(timer));
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
start() {
|
|
189
|
-
if (this.timer || !this._capable) return;
|
|
190
|
-
this.timer = setInterval(() => this.flush(), this.flushIntervalMs);
|
|
191
|
-
if (this.timer.unref) this.timer.unref(); // never keep the process alive
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
stop() {
|
|
195
|
-
if (this.timer) {
|
|
196
|
-
clearInterval(this.timer);
|
|
197
|
-
this.timer = null;
|
|
198
|
-
}
|
|
199
|
-
this.flush();
|
|
200
|
-
}
|
|
201
|
-
}
|
|
1
|
+
// SessionReporter — the proactive, consented "is this user okay?" feed.
|
|
2
|
+
//
|
|
3
|
+
// WHY: the get-in path is self-serve, but if a real user gets stuck or their
|
|
4
|
+
// install breaks, we were blind unless they ran `operator enable` and messaged
|
|
5
|
+
// us — which makes us a bottleneck (docs/user-experience.md §5). This forwards a
|
|
6
|
+
// LIVE, REDACTED stream of session events + install health to bmo-sync, keyed by
|
|
7
|
+
// account, established at first load — so a stuck/broken user is never invisible
|
|
8
|
+
// and never has to ask.
|
|
9
|
+
//
|
|
10
|
+
// PRIVACY — the load-bearing boundary: this feed carries WHAT happened (a turn
|
|
11
|
+
// ran, tool X fired, an error, token/cost), NEVER the words. Chat text, tool
|
|
12
|
+
// inputs, file contents, and paths are reduced to lengths/counts before anything
|
|
13
|
+
// leaves the machine. Conversation *content* is a separate, separately-consented
|
|
14
|
+
// channel (transcript.mjs). `redactEvent` is an ALLOWLIST projection — any event
|
|
15
|
+
// type we don't explicitly model forwards only `{type}`, so a new event can
|
|
16
|
+
// never leak by default. The matching test asserts a secret typed into a chat
|
|
17
|
+
// turn never appears in the payload.
|
|
18
|
+
//
|
|
19
|
+
// Modeled on error-reporter.mjs (fire-and-forget, rate-limited, disable-able).
|
|
20
|
+
// Gated by BOTH consent (user toggle) AND the shared WILD_WORKSPACE_NO_TELEMETRY
|
|
21
|
+
// kill switch; inert without an accountToken (can't key it) or on a localhost
|
|
22
|
+
// bmo-sync URL (dev).
|
|
23
|
+
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import { APP_VERSION } from './config.mjs';
|
|
26
|
+
|
|
27
|
+
function sanitizeUsage(u) {
|
|
28
|
+
if (!u || typeof u !== 'object') return null;
|
|
29
|
+
const out = {
|
|
30
|
+
input_tokens: Number(u.input_tokens) || 0,
|
|
31
|
+
output_tokens: Number(u.output_tokens) || 0,
|
|
32
|
+
};
|
|
33
|
+
if (typeof u.cost_usd === 'number') out.cost_usd = u.cost_usd;
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Project one ActivityBus event to a SAFE shape. Allowlist by type; anything not
|
|
39
|
+
* listed forwards only {type, ts, id}. NEVER returns chat text, tool inputs,
|
|
40
|
+
* file paths, or file contents.
|
|
41
|
+
*/
|
|
42
|
+
export function redactEvent(ev) {
|
|
43
|
+
if (!ev || typeof ev !== 'object') return null;
|
|
44
|
+
const base = { type: ev.type, ts: ev.ts, id: ev.id };
|
|
45
|
+
if (ev.messageId) base.messageId = ev.messageId;
|
|
46
|
+
switch (ev.type) {
|
|
47
|
+
case 'chat-user':
|
|
48
|
+
// The user's prompt — length only, never the words.
|
|
49
|
+
return { ...base, textLen: typeof ev.text === 'string' ? ev.text.length : 0 };
|
|
50
|
+
case 'chat-stream': {
|
|
51
|
+
const c = ev.chunk || {};
|
|
52
|
+
const safe = { ...base, chunkType: c.type };
|
|
53
|
+
if (c.type === 'text' && typeof c.text === 'string') safe.textLen = c.text.length;
|
|
54
|
+
// tool NAME is safe ("Edit", "Bash"); the tool INPUT is not — never forward it.
|
|
55
|
+
if (c.type === 'tool-use') safe.tool = typeof c.name === 'string' ? c.name : c.tool || null;
|
|
56
|
+
if (c.type === 'usage' && c.usage) safe.usage = sanitizeUsage(c.usage);
|
|
57
|
+
if (c.type === 'error') safe.hasError = true; // the flag, not the message
|
|
58
|
+
return safe;
|
|
59
|
+
}
|
|
60
|
+
case 'usage':
|
|
61
|
+
return { ...base, usage: sanitizeUsage(ev.usage) };
|
|
62
|
+
case 'chat-end':
|
|
63
|
+
return { ...base, code: ev.code };
|
|
64
|
+
case 'identity-changed':
|
|
65
|
+
return { ...base, tone: ev.tone || null }; // tone only; drop the agent's name
|
|
66
|
+
case 'onboarded':
|
|
67
|
+
return { ...base, at: ev.at || null };
|
|
68
|
+
case 'agent-changed':
|
|
69
|
+
return { ...base, agentId: ev.agentId || null };
|
|
70
|
+
case 'daemon-status':
|
|
71
|
+
return { ...base, status: ev.status || null };
|
|
72
|
+
case 'operator-action':
|
|
73
|
+
return { ...base, action: ev.action || null }; // action verb, not its detail
|
|
74
|
+
case 'inbox-change':
|
|
75
|
+
return { ...base, count: Array.isArray(ev.snapshot) ? ev.snapshot.length : undefined };
|
|
76
|
+
case 'presence-join':
|
|
77
|
+
case 'presence-leave':
|
|
78
|
+
case 'presence-focus':
|
|
79
|
+
// sessionId + role are safe; `focus` can be a file path → dropped.
|
|
80
|
+
return { ...base, sessionId: ev.sessionId, role: ev.role };
|
|
81
|
+
default:
|
|
82
|
+
// Unknown event → minimal envelope only. Privacy fails CLOSED.
|
|
83
|
+
return { type: ev.type, ts: ev.ts, id: ev.id };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const FLUSH_INTERVAL_MS = 15_000; // batch window
|
|
88
|
+
const MAX_BATCH = 50; // flush early once this many buffer
|
|
89
|
+
const MAX_BUFFER = 500; // hard cap — drop oldest beyond this
|
|
90
|
+
|
|
91
|
+
export class SessionReporter {
|
|
92
|
+
constructor({
|
|
93
|
+
bmoSyncUrl,
|
|
94
|
+
accountToken,
|
|
95
|
+
slug = null,
|
|
96
|
+
workspaceId = 'workspace',
|
|
97
|
+
sessionId = null,
|
|
98
|
+
enabled = true,
|
|
99
|
+
endpointPath = '/api/telemetry',
|
|
100
|
+
flushIntervalMs = FLUSH_INTERVAL_MS,
|
|
101
|
+
maxBatch = MAX_BATCH,
|
|
102
|
+
fetchImpl = (...a) => globalThis.fetch(...a),
|
|
103
|
+
nowImpl = () => Date.now(),
|
|
104
|
+
} = {}) {
|
|
105
|
+
this.bmoSyncUrl = bmoSyncUrl ? bmoSyncUrl.replace(/\/$/, '') : null;
|
|
106
|
+
this.accountToken = accountToken || null;
|
|
107
|
+
this.slug = slug;
|
|
108
|
+
this.workspaceId = workspaceId;
|
|
109
|
+
this.sessionId = sessionId;
|
|
110
|
+
this.endpointPath = endpointPath;
|
|
111
|
+
this.flushIntervalMs = flushIntervalMs;
|
|
112
|
+
this.maxBatch = maxBatch;
|
|
113
|
+
this.fetchImpl = fetchImpl;
|
|
114
|
+
this.nowImpl = nowImpl;
|
|
115
|
+
this.buffer = [];
|
|
116
|
+
this.timer = null;
|
|
117
|
+
// Inert without a token, without a server, or on localhost (dev).
|
|
118
|
+
this._capable =
|
|
119
|
+
Boolean(this.accountToken) &&
|
|
120
|
+
Boolean(this.bmoSyncUrl) &&
|
|
121
|
+
!this.bmoSyncUrl.startsWith('http://127') &&
|
|
122
|
+
!this.bmoSyncUrl.startsWith('http://localhost');
|
|
123
|
+
this.enabled = enabled !== false && this._capable;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Live consent toggle — no restart needed when the user flips observability. */
|
|
127
|
+
setEnabled(on) {
|
|
128
|
+
this.enabled = Boolean(on) && this._capable;
|
|
129
|
+
if (!this.enabled) this.buffer = [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Feed one ActivityBus event. Redacts + buffers; flushes on size. No-op when off. */
|
|
133
|
+
ingest(ev) {
|
|
134
|
+
if (!this.enabled) return;
|
|
135
|
+
const safe = redactEvent(ev);
|
|
136
|
+
if (!safe) return;
|
|
137
|
+
this.buffer.push(safe);
|
|
138
|
+
if (this.buffer.length > MAX_BUFFER) this.buffer.splice(0, this.buffer.length - MAX_BUFFER);
|
|
139
|
+
if (this.buffer.length >= this.maxBatch) this.flush();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** POST the install-health snapshot alongside events (called by the supervisor path too). */
|
|
143
|
+
envelope(events, extra = {}) {
|
|
144
|
+
return {
|
|
145
|
+
account_token: this.accountToken,
|
|
146
|
+
slug: this.slug,
|
|
147
|
+
workspace_id: this.workspaceId,
|
|
148
|
+
session_id: this.sessionId,
|
|
149
|
+
app_version: APP_VERSION,
|
|
150
|
+
os: `${os.platform()}-${os.arch()}`,
|
|
151
|
+
sent_at: Math.floor(this.nowImpl() / 1000),
|
|
152
|
+
events,
|
|
153
|
+
...extra,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Fire-and-forget flush of the current buffer. Never throws. */
|
|
158
|
+
flush() {
|
|
159
|
+
if (!this.enabled || !this.buffer.length) return;
|
|
160
|
+
const events = this.buffer;
|
|
161
|
+
this.buffer = [];
|
|
162
|
+
this._post(this.envelope(events));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
_post(body) {
|
|
166
|
+
const url = `${this.bmoSyncUrl}${this.endpointPath}`;
|
|
167
|
+
const ctrl = new AbortController();
|
|
168
|
+
const timer = setTimeout(() => ctrl.abort(), 5000);
|
|
169
|
+
if (timer.unref) timer.unref();
|
|
170
|
+
let p;
|
|
171
|
+
try {
|
|
172
|
+
// Call synchronously so the request is observable without awaiting a tick.
|
|
173
|
+
p = this.fetchImpl(url, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: { 'content-type': 'application/json' },
|
|
176
|
+
body: JSON.stringify(body),
|
|
177
|
+
signal: ctrl.signal,
|
|
178
|
+
});
|
|
179
|
+
} catch {
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
return; // telemetry must never break the user's path
|
|
182
|
+
}
|
|
183
|
+
Promise.resolve(p)
|
|
184
|
+
.catch(() => {})
|
|
185
|
+
.finally(() => clearTimeout(timer));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
start() {
|
|
189
|
+
if (this.timer || !this._capable) return;
|
|
190
|
+
this.timer = setInterval(() => this.flush(), this.flushIntervalMs);
|
|
191
|
+
if (this.timer.unref) this.timer.unref(); // never keep the process alive
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
stop() {
|
|
195
|
+
if (this.timer) {
|
|
196
|
+
clearInterval(this.timer);
|
|
197
|
+
this.timer = null;
|
|
198
|
+
}
|
|
199
|
+
this.flush();
|
|
200
|
+
}
|
|
201
|
+
}
|