@nurix/nustack 0.23.8 → 0.23.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/census.js +48 -0
- package/dist/commands/gate.js +75 -0
- package/dist/commands/telemetry.js +37 -5
- package/dist/index.js +8 -451
- package/dist/lib/agent/claudeHooks.js +39 -19
- package/dist/lib/census/attribute.js +120 -0
- package/dist/lib/census/report.js +108 -0
- package/dist/lib/census/transcripts.js +62 -0
- package/dist/lib/desktop_settings.js +23 -2
- package/dist/lib/filebeat_installer.js +5 -1
- package/dist/lib/gate/bashRead.js +201 -0
- package/dist/lib/gate/estimate.js +90 -0
- package/dist/lib/gate/hook.js +79 -0
- package/dist/lib/gate/outline.js +146 -0
- package/dist/lib/gate/readGate.js +126 -0
- package/dist/lib/gate/settings.js +24 -0
- package/dist/lib/gate/subcommands.js +1 -0
- package/dist/lib/local_store/database.js +42 -1
- package/dist/lib/local_store/index.js +1 -0
- package/dist/lib/local_store/migrations.js +36 -0
- package/dist/lib/local_store/routing.js +137 -0
- package/dist/lib/nustack_client.js +4 -2
- package/dist/lib/session_telemetry.js +108 -40
- package/dist/lib/telemetry/config_renderer.js +4 -4
- package/dist/lib/telemetry/environment.js +2 -0
- package/dist/lib/telemetry/health_report.js +10 -3
- package/dist/lib/telemetry/part_state.js +168 -0
- package/dist/program.js +491 -0
- package/package.json +2 -2
|
@@ -14,8 +14,9 @@ import { INPUTS_FILE, LOCAL_OUTPUT_HEADER, renderCentralConfig, renderInputsFile
|
|
|
14
14
|
import { applyConfigChange, removeInputFile } from "./telemetry/reload.js";
|
|
15
15
|
import { discoverProviderBindings } from "./telemetry/provider_paths.js";
|
|
16
16
|
import { reportTelemetryHealth } from "./telemetry/health_report.js";
|
|
17
|
+
import { DEFAULT_SESSION_INDEX, isSameOutput, machineVerdict, partAnswerOf, readPartState, recordPartState, } from "./telemetry/part_state.js";
|
|
17
18
|
const TELEMETRY_SERVICE = "elastic-observability";
|
|
18
|
-
const DEFAULT_INDEX =
|
|
19
|
+
const DEFAULT_INDEX = DEFAULT_SESSION_INDEX;
|
|
19
20
|
const NO_OP_LOG = () => { };
|
|
20
21
|
function defaultSeams() {
|
|
21
22
|
return {
|
|
@@ -123,53 +124,68 @@ export async function ensureBindingTelemetry(opts) {
|
|
|
123
124
|
await reportBestEffort(baseUrl, store, seams, accessToken);
|
|
124
125
|
return;
|
|
125
126
|
}
|
|
126
|
-
let
|
|
127
|
+
let brokerError = null;
|
|
127
128
|
const credential = accessToken
|
|
128
129
|
? await fetchBrokeredCredential(baseUrl, accessToken, {
|
|
129
130
|
projectId: binding.projectId,
|
|
130
131
|
service: TELEMETRY_SERVICE,
|
|
131
132
|
}).catch((error) => {
|
|
132
|
-
|
|
133
|
+
brokerError = error;
|
|
133
134
|
return null;
|
|
134
135
|
})
|
|
135
136
|
: null;
|
|
136
|
-
|
|
137
|
+
const brokerFailure = brokerError === null ? null : message(brokerError);
|
|
138
|
+
const answer = accessToken ? partAnswerOf(binding.organizationId, { credential, error: brokerError }) : null;
|
|
139
|
+
const parts = answer ? recordPartState({ projects: { [binding.projectId]: answer } }) : readPartState();
|
|
140
|
+
const candidate = credential?.url && answer ? { destination: answer.destination ?? "nurix", url: credential.url, index: answer.index ?? DEFAULT_INDEX } : null;
|
|
141
|
+
const verdict = machineVerdict(bindingsOf(store, binding.id, true), parts, candidate);
|
|
142
|
+
const output = verdict.selection.output;
|
|
143
|
+
if (verdict.selection.conflict) {
|
|
144
|
+
recordPartState({ conflict: true });
|
|
145
|
+
await applyInputs(store, binding.id, verdict, deviceRef, userId, seams);
|
|
146
|
+
disableActivity(store, binding.id);
|
|
147
|
+
recordDegraded(store, "destination_conflict");
|
|
148
|
+
await reportBestEffort(baseUrl, store, seams, accessToken);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (!output) {
|
|
152
|
+
recordPartState({ conflict: false });
|
|
153
|
+
if (answer?.state === "off") {
|
|
154
|
+
await applyInputs(store, binding.id, verdict, deviceRef, userId, seams);
|
|
155
|
+
disableActivity(store, binding.id);
|
|
156
|
+
}
|
|
137
157
|
const unserved = brokerFailure !== null && brokerFailure.includes("does not serve");
|
|
138
158
|
recordDegraded(store, unserved ? "credential_plane_unserved" : "no_output_credential");
|
|
139
159
|
await reportBestEffort(baseUrl, store, seams, accessToken);
|
|
140
160
|
return;
|
|
141
161
|
}
|
|
142
|
-
if (credential
|
|
143
|
-
const index = credential.meta?.index || DEFAULT_INDEX;
|
|
162
|
+
if (credential && candidate && isSameOutput(output, candidate)) {
|
|
144
163
|
const central = {
|
|
145
164
|
relativePath: "filebeat.yml",
|
|
146
|
-
contents: renderCentralConfig({ elkUrl:
|
|
165
|
+
contents: renderCentralConfig({ elkUrl: candidate.url, apiKey: credential.key, index: candidate.index }),
|
|
147
166
|
};
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
167
|
+
const live = await readFile(mainConfigPath(), "utf8").catch(() => null);
|
|
168
|
+
if (live !== central.contents) {
|
|
169
|
+
const centralApply = await applyConfigChange({
|
|
170
|
+
files: [central],
|
|
171
|
+
scope: "central",
|
|
172
|
+
context: centralReloadContext(seams, filebeatDir()),
|
|
173
|
+
});
|
|
174
|
+
if (!centralApply.ok) {
|
|
175
|
+
recordDegraded(store, centralApply.errorCode);
|
|
176
|
+
await reportBestEffort(baseUrl, store, seams, accessToken);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
157
179
|
}
|
|
158
180
|
}
|
|
181
|
+
recordPartState({ output, conflict: false });
|
|
159
182
|
if (!seams.serviceRegistered())
|
|
160
183
|
await seams.registerService();
|
|
161
|
-
|
|
162
|
-
await removeOrphanInputs();
|
|
163
|
-
const inputsApply = await applyConfigChange({
|
|
164
|
-
files: [{ relativePath: INPUTS_FILE, contents: inputs }],
|
|
165
|
-
scope: "inputs",
|
|
166
|
-
context: seams.reloadContext(filebeatDir()),
|
|
167
|
-
});
|
|
168
|
-
if (!inputsApply.ok) {
|
|
169
|
-
recordDegraded(store, inputsApply.errorCode);
|
|
184
|
+
if (!(await applyInputs(store, binding.id, verdict, deviceRef, userId, seams, binding))) {
|
|
170
185
|
await reportBestEffort(baseUrl, store, seams, accessToken);
|
|
171
186
|
return;
|
|
172
187
|
}
|
|
188
|
+
const isShipping = verdict.shipping.has(binding.id);
|
|
173
189
|
for (const discovered of discoverProviderBindings(binding.repositoryRoot)) {
|
|
174
190
|
upsertActivityBinding(store, {
|
|
175
191
|
id: activityRowId(binding.id, discovered.provider, discovered.pathFingerprint),
|
|
@@ -177,7 +193,7 @@ export async function ensureBindingTelemetry(opts) {
|
|
|
177
193
|
provider: discovered.provider,
|
|
178
194
|
providerProjectPath: discovered.absolutePath,
|
|
179
195
|
pathFingerprint: discovered.pathFingerprint,
|
|
180
|
-
state: discovered.state,
|
|
196
|
+
state: isShipping ? discovered.state : "disabled",
|
|
181
197
|
});
|
|
182
198
|
}
|
|
183
199
|
upsertFilebeatState(store, {
|
|
@@ -226,6 +242,7 @@ export async function setLocalOutput(opts) {
|
|
|
226
242
|
recordDegraded(store, centralApply.errorCode);
|
|
227
243
|
return { ok: false, errorCode: centralApply.errorCode };
|
|
228
244
|
}
|
|
245
|
+
recordPartState({ output: { destination: "local", url: opts.elkUrl, index: opts.index || DEFAULT_INDEX }, conflict: false });
|
|
229
246
|
if (!seams.serviceRegistered())
|
|
230
247
|
await seams.registerService();
|
|
231
248
|
upsertFilebeatState(store, {
|
|
@@ -251,7 +268,8 @@ export async function removeBindingTelemetry(opts) {
|
|
|
251
268
|
try {
|
|
252
269
|
const device = readDeviceState(store);
|
|
253
270
|
await removeOrphanInputs();
|
|
254
|
-
const
|
|
271
|
+
const verdict = machineVerdict(bindingsOf(store, bindingId, false), readPartState());
|
|
272
|
+
const inputs = renderInputs(store, { id: bindingId, enabled: false }, device?.deviceRef ?? "", device?.activeUserId ?? "", verdict);
|
|
255
273
|
if (inputs === null) {
|
|
256
274
|
await removeInputFile(filebeatDir(), INPUTS_FILE);
|
|
257
275
|
}
|
|
@@ -266,20 +284,70 @@ export async function removeBindingTelemetry(opts) {
|
|
|
266
284
|
recordDegraded(store, applied.errorCode);
|
|
267
285
|
}
|
|
268
286
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
287
|
+
disableActivity(store, bindingId);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function disableActivity(store, bindingId) {
|
|
293
|
+
for (const row of listActivityBindings(store, bindingId)) {
|
|
294
|
+
upsertActivityBinding(store, {
|
|
295
|
+
id: row.id,
|
|
296
|
+
bindingId: row.bindingId,
|
|
297
|
+
provider: row.provider,
|
|
298
|
+
providerProjectPath: row.providerProjectPath,
|
|
299
|
+
pathFingerprint: row.pathFingerprint,
|
|
300
|
+
state: "disabled",
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function bindingsOf(store, bindingId, folderEnabled) {
|
|
305
|
+
return listActiveBindings(store).map((b) => ({ bindingId: b.id, projectId: b.projectId, folderEnabled: b.id === bindingId ? folderEnabled : b.telemetryEnabled }));
|
|
306
|
+
}
|
|
307
|
+
async function applyInputs(store, bindingId, verdict, deviceRef, userId, seams, binding) {
|
|
308
|
+
const inputs = renderInputs(store, { id: bindingId, binding, enabled: true }, deviceRef, userId, verdict) ?? "";
|
|
309
|
+
await removeOrphanInputs();
|
|
310
|
+
const inputsApply = await applyConfigChange({
|
|
311
|
+
files: [{ relativePath: INPUTS_FILE, contents: inputs }],
|
|
312
|
+
scope: "inputs",
|
|
313
|
+
context: seams.reloadContext(filebeatDir()),
|
|
314
|
+
});
|
|
315
|
+
if (!inputsApply.ok)
|
|
316
|
+
recordDegraded(store, inputsApply.errorCode);
|
|
317
|
+
return inputsApply.ok;
|
|
318
|
+
}
|
|
319
|
+
export async function reconcileAllBindings(opts) {
|
|
320
|
+
const { store, baseUrl } = opts;
|
|
321
|
+
const bindings = listActiveBindings(store);
|
|
322
|
+
let reconciled = 0;
|
|
323
|
+
try {
|
|
324
|
+
const machine = readFilebeatState(store);
|
|
325
|
+
if (!optedOutByEnv() && (!machine || machine.telemetryEnabled)) {
|
|
326
|
+
for (const binding of bindings) {
|
|
327
|
+
await ensureBindingTelemetry({ baseUrl, store, binding, seams: opts.seams });
|
|
328
|
+
reconciled += 1;
|
|
329
|
+
}
|
|
278
330
|
}
|
|
279
331
|
}
|
|
332
|
+
finally {
|
|
333
|
+
recordPartStateBestEffort();
|
|
334
|
+
}
|
|
335
|
+
const parts = readPartState();
|
|
336
|
+
return { reconciled, parts: countParts(bindings, parts), conflict: parts.conflict };
|
|
337
|
+
}
|
|
338
|
+
function recordPartStateBestEffort() {
|
|
339
|
+
try {
|
|
340
|
+
recordPartState();
|
|
341
|
+
}
|
|
280
342
|
catch {
|
|
281
343
|
}
|
|
282
344
|
}
|
|
345
|
+
function countParts(bindings, parts) {
|
|
346
|
+
const counts = { on: 0, off: 0, unknown: 0 };
|
|
347
|
+
for (const projectId of new Set(bindings.map((b) => b.projectId)))
|
|
348
|
+
counts[parts.projects[projectId]?.state ?? "unknown"] += 1;
|
|
349
|
+
return counts;
|
|
350
|
+
}
|
|
283
351
|
export async function switchMachineOff(opts) {
|
|
284
352
|
const seams = { ...defaultSeams(), ...opts.seams };
|
|
285
353
|
const { store } = opts;
|
|
@@ -310,15 +378,15 @@ export async function switchMachineOff(opts) {
|
|
|
310
378
|
upsertFilebeatState(store, { processState: "stopped" });
|
|
311
379
|
return "stopped";
|
|
312
380
|
}
|
|
313
|
-
function renderInputs(store, reconciled, deviceRef, userId) {
|
|
381
|
+
function renderInputs(store, reconciled, deviceRef, userId, verdict) {
|
|
314
382
|
const byId = new Map();
|
|
315
383
|
for (const b of listActiveBindings(store)) {
|
|
316
|
-
byId.set(b.id, { binding: toTelemetryBinding(b, deviceRef, userId), enabled: b.telemetryEnabled });
|
|
384
|
+
byId.set(b.id, { binding: toTelemetryBinding(b, deviceRef, userId), enabled: b.telemetryEnabled && verdict.shipping.has(b.id) });
|
|
317
385
|
}
|
|
318
386
|
const known = reconciled.binding ? toTelemetryBinding(reconciled.binding, deviceRef, userId) : byId.get(reconciled.id)?.binding;
|
|
319
387
|
if (known)
|
|
320
|
-
byId.set(reconciled.id, { binding: known, enabled: reconciled.enabled });
|
|
321
|
-
return byId.size === 0 ? null : renderInputsFile([...byId.values()], { deviceRef, userId });
|
|
388
|
+
byId.set(reconciled.id, { binding: known, enabled: reconciled.enabled && verdict.shipping.has(reconciled.id) });
|
|
389
|
+
return byId.size === 0 ? null : renderInputsFile([...byId.values()], { deviceRef, userId }, { dropAll: verdict.dropCodex });
|
|
322
390
|
}
|
|
323
391
|
async function removeOrphanInputs() {
|
|
324
392
|
const inputsDirPath = path.join(filebeatDir(), "inputs.d");
|
|
@@ -139,10 +139,10 @@ export function renderBindingInput(binding, enabled = true) {
|
|
|
139
139
|
});
|
|
140
140
|
return enabled ? block : block.replace("\n processors:\n", `\n processors:\n${SWITCHED_OFF}`);
|
|
141
141
|
}
|
|
142
|
-
export function renderCodexSharedInput(entries, device) {
|
|
142
|
+
export function renderCodexSharedInput(entries, device, options = {}) {
|
|
143
143
|
const ordered = [...entries].sort((a, b) => a.binding.bindingId < b.binding.bindingId ? -1 : a.binding.bindingId > b.binding.bindingId ? 1 : 0);
|
|
144
144
|
const sorted = ordered.filter((entry) => entry.enabled).map((entry) => entry.binding);
|
|
145
|
-
const drops = sorted.length === 0 ? SWITCHED_OFF : "";
|
|
145
|
+
const drops = sorted.length === 0 || options.dropAll === true ? SWITCHED_OFF : "";
|
|
146
146
|
const conditionals = sorted
|
|
147
147
|
.map((binding) => `
|
|
148
148
|
- if:
|
|
@@ -183,10 +183,10 @@ ${drops} - add_fields:
|
|
|
183
183
|
ignore_missing: true
|
|
184
184
|
`;
|
|
185
185
|
}
|
|
186
|
-
export function renderInputsFile(entries, device) {
|
|
186
|
+
export function renderInputsFile(entries, device, options = {}) {
|
|
187
187
|
const sorted = [...entries].sort((a, b) => a.binding.bindingId < b.binding.bindingId ? -1 : a.binding.bindingId > b.binding.bindingId ? 1 : 0);
|
|
188
188
|
const blocks = sorted.map((entry) => renderBindingInput(entry.binding, entry.enabled));
|
|
189
|
-
return INPUTS_HEADER + blocks.join("") + renderCodexSharedInput(sorted, device);
|
|
189
|
+
return INPUTS_HEADER + blocks.join("") + renderCodexSharedInput(sorted, device, options);
|
|
190
190
|
}
|
|
191
191
|
export function splitInputBlocks(contents) {
|
|
192
192
|
const starts = [...contents.matchAll(BLOCK_MARKER)].map((match) => ({
|
|
@@ -9,6 +9,7 @@ import { REDACTION_POLICY_VERSION, } from "./redaction_policy.js";
|
|
|
9
9
|
import { INPUTS_FILE, TELEMETRY_CONFIG_VERSION, desiredConfigHash, renderBindingInput, splitInputBlocks, } from "./config_renderer.js";
|
|
10
10
|
import { discoverProviderBindings } from "./provider_paths.js";
|
|
11
11
|
import { readObservedConfigBytes } from "./reload.js";
|
|
12
|
+
import { machineVerdict, readPartState } from "./part_state.js";
|
|
12
13
|
export function defaultProbes() {
|
|
13
14
|
return {
|
|
14
15
|
binaryPresent: () => existsSync(filebeatBinPath()),
|
|
@@ -36,7 +37,11 @@ export async function collectTelemetryStatus(store, probes = defaultProbes(), op
|
|
|
36
37
|
const userId = device?.activeUserId ?? "";
|
|
37
38
|
const folders = [];
|
|
38
39
|
const activity = [];
|
|
39
|
-
|
|
40
|
+
const bindings = listActiveBindings(store);
|
|
41
|
+
const parts = readPartState();
|
|
42
|
+
const verdict = machineVerdict(bindings.map((b) => ({ bindingId: b.id, projectId: b.projectId, folderEnabled: b.telemetryEnabled })), parts);
|
|
43
|
+
for (const binding of bindings) {
|
|
44
|
+
const isRendered = verdict.shipping.has(binding.id);
|
|
40
45
|
const rendered = renderBindingInput({
|
|
41
46
|
bindingId: binding.id,
|
|
42
47
|
repoRoot: binding.repositoryRoot,
|
|
@@ -57,9 +62,11 @@ export async function collectTelemetryStatus(store, probes = defaultProbes(), op
|
|
|
57
62
|
desiredConfigHash: desired,
|
|
58
63
|
observedConfigHash: observed,
|
|
59
64
|
redactionPolicyVersion: REDACTION_POLICY_VERSION,
|
|
65
|
+
partState: parts.projects[binding.projectId]?.state ?? "unknown",
|
|
66
|
+
shipping: isRendered && verdict.selection.output !== null,
|
|
60
67
|
state: folderState({
|
|
61
68
|
telemetryEnabled,
|
|
62
|
-
folderEnabled: binding.telemetryEnabled,
|
|
69
|
+
folderEnabled: binding.telemetryEnabled && isRendered,
|
|
63
70
|
binaryPresent,
|
|
64
71
|
desired,
|
|
65
72
|
observed,
|
|
@@ -75,7 +82,7 @@ export async function collectTelemetryStatus(store, probes = defaultProbes(), op
|
|
|
75
82
|
providerProjectKey: discovered.providerProjectKey,
|
|
76
83
|
pathFingerprint: discovered.pathFingerprint,
|
|
77
84
|
discoveryMethod: discovered.discoveryMethod,
|
|
78
|
-
state: !telemetryEnabled || !binding.telemetryEnabled ? "disabled" : baseState,
|
|
85
|
+
state: !telemetryEnabled || !binding.telemetryEnabled || !isRendered ? "disabled" : baseState,
|
|
79
86
|
lastObservedAtMs: row?.lastObservedAtMs ?? null,
|
|
80
87
|
});
|
|
81
88
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { filebeatDir, mainConfigPath } from "../filebeat_installer.js";
|
|
6
|
+
import { locksDir } from "../local_store/paths.js";
|
|
7
|
+
import { DRAIN_CHILD_VARIABLE, isFlagSet } from "../records/environment.js";
|
|
8
|
+
import { BROKERED_OUTPUT_HEADER, LOCAL_OUTPUT_HEADER } from "./config_renderer.js";
|
|
9
|
+
import { TELEMETRY_CHILD_VARIABLE } from "./environment.js";
|
|
10
|
+
export const TELEMETRY_PART_OFF_REASON = "telemetry_part_off";
|
|
11
|
+
export const TELEMETRY_PART_DETAIL = "telemetryPart";
|
|
12
|
+
export const TELEMETRY_DESTINATION_DETAIL = "telemetryDestination";
|
|
13
|
+
export const DEFAULT_SESSION_INDEX = "claude-sessions";
|
|
14
|
+
export const PART_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
|
15
|
+
const PART_STATE_SCHEMA_VERSION = 1;
|
|
16
|
+
export function partStatePath() {
|
|
17
|
+
return path.join(filebeatDir(), "part-state.json");
|
|
18
|
+
}
|
|
19
|
+
export function telemetryReconcileLockPath() {
|
|
20
|
+
return path.join(locksDir(), "telemetry-reconcile.lock");
|
|
21
|
+
}
|
|
22
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
|
+
const stringOrNull = (value) => (typeof value === "string" && value.length > 0 ? value : null);
|
|
24
|
+
function readOutput(value) {
|
|
25
|
+
if (!isRecord(value))
|
|
26
|
+
return null;
|
|
27
|
+
const destination = value.destination;
|
|
28
|
+
if (destination !== "nurix" && destination !== "tenant" && destination !== "local")
|
|
29
|
+
return null;
|
|
30
|
+
return { destination, url: stringOrNull(value.url), index: stringOrNull(value.index) };
|
|
31
|
+
}
|
|
32
|
+
function readProject(value) {
|
|
33
|
+
if (!isRecord(value) || (value.state !== "on" && value.state !== "off") || typeof value.organizationId !== "string")
|
|
34
|
+
return null;
|
|
35
|
+
const destination = value.destination === "nurix" || value.destination === "tenant" ? value.destination : null;
|
|
36
|
+
return { organizationId: value.organizationId, state: value.state, destination, url: stringOrNull(value.url), index: stringOrNull(value.index), checkedAt: String(value.checkedAt ?? "") };
|
|
37
|
+
}
|
|
38
|
+
function seedOutput() {
|
|
39
|
+
try {
|
|
40
|
+
const header = readFileSync(mainConfigPath(), "utf8").split("\n", 1)[0];
|
|
41
|
+
if (header === LOCAL_OUTPUT_HEADER)
|
|
42
|
+
return { destination: "local", url: null, index: null };
|
|
43
|
+
if (header === BROKERED_OUTPUT_HEADER)
|
|
44
|
+
return { destination: "nurix", url: null, index: null };
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function readPartState() {
|
|
52
|
+
const empty = { schemaVersion: PART_STATE_SCHEMA_VERSION, checkedAt: null, output: null, conflict: false, projects: {} };
|
|
53
|
+
let state = empty;
|
|
54
|
+
try {
|
|
55
|
+
const parsed = JSON.parse(readFileSync(partStatePath(), "utf8"));
|
|
56
|
+
if (isRecord(parsed) && parsed.schemaVersion === PART_STATE_SCHEMA_VERSION) {
|
|
57
|
+
const projects = {};
|
|
58
|
+
for (const [projectId, entry] of Object.entries(isRecord(parsed.projects) ? parsed.projects : {})) {
|
|
59
|
+
const project = readProject(entry);
|
|
60
|
+
if (project)
|
|
61
|
+
projects[projectId] = project;
|
|
62
|
+
}
|
|
63
|
+
state = { ...empty, checkedAt: stringOrNull(parsed.checkedAt), output: readOutput(parsed.output), conflict: parsed.conflict === true, projects };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
}
|
|
68
|
+
return state.output ? state : { ...state, output: seedOutput() };
|
|
69
|
+
}
|
|
70
|
+
export function recordPartState(change = {}) {
|
|
71
|
+
const current = readPartState();
|
|
72
|
+
const projects = { ...current.projects };
|
|
73
|
+
for (const [projectId, entry] of Object.entries(change.projects ?? {})) {
|
|
74
|
+
if (entry)
|
|
75
|
+
projects[projectId] = entry;
|
|
76
|
+
else
|
|
77
|
+
delete projects[projectId];
|
|
78
|
+
}
|
|
79
|
+
const next = {
|
|
80
|
+
schemaVersion: PART_STATE_SCHEMA_VERSION,
|
|
81
|
+
checkedAt: new Date().toISOString(),
|
|
82
|
+
output: change.output === undefined ? current.output : change.output,
|
|
83
|
+
conflict: change.conflict ?? current.conflict,
|
|
84
|
+
projects,
|
|
85
|
+
};
|
|
86
|
+
const target = partStatePath();
|
|
87
|
+
mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
88
|
+
const staged = `${target}.${process.pid}.tmp`;
|
|
89
|
+
writeFileSync(staged, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
90
|
+
renameSync(staged, target);
|
|
91
|
+
return next;
|
|
92
|
+
}
|
|
93
|
+
export function partAnswerOf(organizationId, answer) {
|
|
94
|
+
const checkedAt = new Date().toISOString();
|
|
95
|
+
if (answer.credential) {
|
|
96
|
+
const index = typeof answer.credential.meta?.index === "string" && answer.credential.meta.index ? answer.credential.meta.index : DEFAULT_SESSION_INDEX;
|
|
97
|
+
const destination = answer.credential.meta?.destination === "tenant" ? "tenant" : "nurix";
|
|
98
|
+
return { organizationId, state: "on", destination, url: answer.credential.url, index, checkedAt };
|
|
99
|
+
}
|
|
100
|
+
const details = isRecord(answer.error) && isRecord(answer.error.details) ? answer.error.details : null;
|
|
101
|
+
if (!details)
|
|
102
|
+
return null;
|
|
103
|
+
if (details.reason === TELEMETRY_PART_OFF_REASON)
|
|
104
|
+
return { organizationId, state: "off", destination: null, url: null, index: null, checkedAt };
|
|
105
|
+
if (details[TELEMETRY_PART_DETAIL] !== "on")
|
|
106
|
+
return null;
|
|
107
|
+
const named = details[TELEMETRY_DESTINATION_DETAIL];
|
|
108
|
+
return { organizationId, state: "on", destination: named === "nurix" || named === "tenant" ? named : null, url: null, index: null, checkedAt };
|
|
109
|
+
}
|
|
110
|
+
export const isSameOutput = (a, b) => a !== null && b !== null && a.destination === b.destination && a.url === b.url && a.index === b.index;
|
|
111
|
+
export function matchesOutput(entry, output) {
|
|
112
|
+
if (!output || entry?.state === "off")
|
|
113
|
+
return false;
|
|
114
|
+
if (output.destination !== "tenant")
|
|
115
|
+
return entry?.destination !== "tenant";
|
|
116
|
+
return entry?.state === "on" && entry.destination === "tenant" && entry.url === output.url && entry.index === output.index;
|
|
117
|
+
}
|
|
118
|
+
export function selectMachineOutput(input) {
|
|
119
|
+
const live = input.bindings.filter((binding) => binding.folderEnabled && input.projects[binding.projectId]?.state !== "off");
|
|
120
|
+
const tenantOutputs = new Map();
|
|
121
|
+
for (const binding of live) {
|
|
122
|
+
const entry = input.projects[binding.projectId];
|
|
123
|
+
if (entry?.destination === "tenant" && entry.url !== null)
|
|
124
|
+
tenantOutputs.set(`${entry.url}\n${entry.index}`, { destination: "tenant", url: entry.url, index: entry.index });
|
|
125
|
+
}
|
|
126
|
+
if (tenantOutputs.size > 1)
|
|
127
|
+
return { output: input.current, shippingBindingIds: [], conflict: true };
|
|
128
|
+
const tenant = [...tenantOutputs.values()][0] ?? null;
|
|
129
|
+
let output = input.current;
|
|
130
|
+
if (tenant && (isSameOutput(tenant, input.candidate) || isSameOutput(tenant, input.current)))
|
|
131
|
+
output = tenant;
|
|
132
|
+
else if (!tenant && input.candidate?.destination === "nurix")
|
|
133
|
+
output = input.candidate;
|
|
134
|
+
const shippingBindingIds = live.filter((binding) => matchesOutput(input.projects[binding.projectId], output)).map((binding) => binding.bindingId);
|
|
135
|
+
return { output, shippingBindingIds, conflict: false };
|
|
136
|
+
}
|
|
137
|
+
export function machineVerdict(bindings, parts, candidate = null) {
|
|
138
|
+
const selection = selectMachineOutput({ bindings, projects: parts.projects, current: parts.output, candidate });
|
|
139
|
+
const isOff = (projectId) => parts.projects[projectId]?.state === "off";
|
|
140
|
+
const live = bindings.filter((binding) => binding.folderEnabled && !isOff(binding.projectId)).map((binding) => binding.bindingId);
|
|
141
|
+
const shipping = new Set(selection.conflict ? [] : selection.output === null ? live : selection.shippingBindingIds);
|
|
142
|
+
const dropCodex = selection.conflict || selection.output?.destination === "tenant" || bindings.some((binding) => isOff(binding.projectId));
|
|
143
|
+
return { selection, shipping, dropCodex };
|
|
144
|
+
}
|
|
145
|
+
export function isPartCheckStale(env = process.env, now = Date.now()) {
|
|
146
|
+
try {
|
|
147
|
+
if (isFlagSet(DRAIN_CHILD_VARIABLE, env) || isFlagSet(TELEMETRY_CHILD_VARIABLE, env) || env.NUSTACK_TELEMETRY === "off")
|
|
148
|
+
return false;
|
|
149
|
+
if (!statSync(mainConfigPath(), { throwIfNoEntry: false }))
|
|
150
|
+
return false;
|
|
151
|
+
const file = statSync(partStatePath(), { throwIfNoEntry: false });
|
|
152
|
+
return !file || now - file.mtimeMs > PART_CHECK_INTERVAL_MS;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
export function startTelemetryReconcile(options = {}) {
|
|
159
|
+
try {
|
|
160
|
+
const entry = fileURLToPath(new URL("../../index.js", import.meta.url));
|
|
161
|
+
const args = [entry, ...(options.nustackUrl ? ["--nustack-url", options.nustackUrl] : []), "telemetry", "reconcile", "--quiet"];
|
|
162
|
+
const child = spawn(process.execPath, args, { detached: true, stdio: "ignore", env: { ...process.env, [TELEMETRY_CHILD_VARIABLE]: "1" } });
|
|
163
|
+
child.on("error", () => { });
|
|
164
|
+
child.unref();
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
}
|
|
168
|
+
}
|