@nurix/nustack 0.23.7 → 0.23.9

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.
@@ -6,7 +6,11 @@ import { getBindingByRepositoryRoot, listActiveBindings, setBindingTelemetry, }
6
6
  import { upsertFilebeatState } from "../lib/local_store/filebeat_state.js";
7
7
  import { ensureFilebeatBinary } from "../lib/filebeat_installer.js";
8
8
  import { collectTelemetryStatus } from "../lib/telemetry/health_report.js";
9
- import { ensureBindingTelemetry, setLocalOutput, switchMachineOff } from "../lib/session_telemetry.js";
9
+ import { ensureBindingTelemetry, reconcileAllBindings, setLocalOutput, switchMachineOff } from "../lib/session_telemetry.js";
10
+ import { readPartState, telemetryReconcileLockPath } from "../lib/telemetry/part_state.js";
11
+ import { TELEMETRY_CHILD_VARIABLE } from "../lib/telemetry/environment.js";
12
+ import { isFlagSet } from "../lib/records/environment.js";
13
+ import { acquireDrainLock, releaseDrainLock } from "../lib/records/drain.js";
10
14
  import { resolveRepository } from "../lib/git-sync/core/repository.js";
11
15
  function resolveTargetBinding(store, options) {
12
16
  if (options.device)
@@ -28,13 +32,37 @@ export async function runTelemetry(action, options) {
28
32
  const store = await openLocalStore();
29
33
  try {
30
34
  const verbose = { localVerbose: options.localVerbose === true };
35
+ if (action === "reconcile") {
36
+ const isChild = isFlagSet(TELEMETRY_CHILD_VARIABLE);
37
+ const lockPath = telemetryReconcileLockPath();
38
+ if (isChild && !acquireDrainLock(lockPath))
39
+ return;
40
+ try {
41
+ const result = await reconcileAllBindings({ baseUrl: resolveNuStackUrl(options), store });
42
+ if (options.quiet)
43
+ return;
44
+ if (json) {
45
+ printJsonSuccess("telemetry reconcile", { scope: "device", ...result });
46
+ return;
47
+ }
48
+ p.intro("nustack telemetry reconcile");
49
+ p.log.success(`Reconciled ${result.reconciled} folder${result.reconciled === 1 ? "" : "s"}; parts on ${result.parts.on}, off ${result.parts.off}, unknown ${result.parts.unknown}${result.conflict ? "; two telemetry destinations conflict, so nothing ships" : ""}.`);
50
+ p.outro("Telemetry reconciled.");
51
+ }
52
+ finally {
53
+ if (isChild)
54
+ releaseDrainLock(lockPath);
55
+ }
56
+ return;
57
+ }
31
58
  if (action === "status") {
32
59
  const status = await collectTelemetryStatus(store, undefined, verbose);
33
60
  if (json) {
34
61
  printJsonSuccess("telemetry status", status);
35
62
  return;
36
63
  }
37
- const shipping = status.folders.filter((f) => f.enabled).length;
64
+ const shipping = status.folders.filter((f) => f.enabled && f.shipping).length;
65
+ const partsOff = status.folders.filter((f) => f.partState === "off").length;
38
66
  p.intro("nustack telemetry status");
39
67
  p.note([
40
68
  `telemetry_enabled: ${status.telemetryEnabled}`,
@@ -45,6 +73,7 @@ export async function runTelemetry(action, options) {
45
73
  `install state: ${status.filebeatState}${status.lastErrorCode ? ` (${status.lastErrorCode})` : ""}`,
46
74
  `config version: ${status.desiredConfigVersion}`,
47
75
  `folders: ${shipping} of ${status.folders.length} shipping`,
76
+ `telemetry part: ${partsOff === 0 ? "on for every folder's project" : `off for ${partsOff} folder${partsOff === 1 ? "" : "s"}`}`,
48
77
  `last cloud report: ${status.lastHealthAtMs ? new Date(status.lastHealthAtMs).toISOString() : "never"}`,
49
78
  ].join("\n"), "Telemetry");
50
79
  p.outro("Telemetry status.");
@@ -99,9 +128,12 @@ export async function runTelemetry(action, options) {
99
128
  return;
100
129
  }
101
130
  p.intro(`nustack telemetry ${action}`);
102
- p.log.success(enabled
103
- ? `${folderLabel(target)} is shipping. Other folders are unchanged.`
104
- : `${folderLabel(target)} has stopped shipping; nothing it records while off will ever ship. Other folders are unchanged.`);
131
+ const isPartOff = readPartState().projects[target.projectId]?.state === "off";
132
+ p.log.success(enabled && isPartOff
133
+ ? `${folderLabel(target)} is switched on, but the tenant's telemetry part is off for this project; this folder ships when it is switched back on.`
134
+ : enabled
135
+ ? `${folderLabel(target)} is shipping. Other folders are unchanged.`
136
+ : `${folderLabel(target)} has stopped shipping; nothing it records while off will ever ship. Other folders are unchanged.`);
105
137
  p.outro(`Run \`nustack telemetry ${enabled ? "disable" : "enable"}\` here to reverse it.`);
106
138
  return;
107
139
  }
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ import { runHarnessMount, runHarnessStatus } from "./commands/harness.js";
34
34
  import { runSessionsList, runSessionsPrune, runSessionsRecord, runSessionsServe, } from "./commands/sessions.js";
35
35
  import { registerReviewCommands } from "./commands/review.js";
36
36
  import { registerRecordsCommands, startDrainIfDue } from "./commands/records.js";
37
+ import { isPartCheckStale, startTelemetryReconcile } from "./lib/telemetry/part_state.js";
37
38
  import { RECORD_ALIAS_GROUPS, registerRecordAliases } from "./commands/recordAliases.js";
38
39
  import { runAgentContext, runAgentHookInstall, runAgentHookRemove } from "./commands/agent.js";
39
40
  import { printJsonError } from "./lib/json_output.js";
@@ -180,14 +181,15 @@ device
180
181
  .action(async (deviceId, options) => runDeviceRevoke(deviceId, options, program.opts()));
181
182
  program
182
183
  .command("telemetry [action]")
183
- .description("Inspect or control telemetry (status | enable | disable | reinstall | output). Inside a bound folder, enable/disable move that folder alone; output points the shipper at your own Elastic, its key read from NUSTACK_ELASTIC_API_KEY")
184
+ .description("Inspect or control telemetry (status | enable | disable | reinstall | output | reconcile). Inside a bound folder, enable/disable move that folder alone; output points the shipper at your own Elastic, its key read from NUSTACK_ELASTIC_API_KEY; reconcile asks the server for each project's telemetry part")
184
185
  .option("--json", "emit the versioned machine envelope")
185
186
  .option("--device", "move this machine's switch instead of the current folder's")
186
187
  .option("--local-verbose", "include each folder's absolute path (local consumers only — never pipe into a log)")
187
188
  .option("--elastic-url <url>", "output: the Elastic endpoint (default NUSTACK_ELASTIC_URL)")
188
189
  .option("--index <name>", "output: the index events are written to (default claude-sessions)")
190
+ .option("--quiet", "reconcile: print nothing")
189
191
  .action(async (action, options) => {
190
- const allowed = ["status", "enable", "disable", "reinstall", "output"];
192
+ const allowed = ["status", "enable", "disable", "reinstall", "output", "reconcile"];
191
193
  const chosen = (allowed.includes(action ?? "") ? action : "status");
192
194
  return runTelemetry(chosen, {
193
195
  ...program.opts(),
@@ -196,6 +198,7 @@ program
196
198
  localVerbose: options.localVerbose,
197
199
  elasticUrl: options.elasticUrl,
198
200
  index: options.index,
201
+ quiet: options.quiet,
199
202
  });
200
203
  });
201
204
  const toLimit = (value) => Number.parseInt(value, 10);
@@ -245,6 +248,8 @@ program.hook("preAction", async (_program, action) => {
245
248
  const group = action.parent?.name() ?? "";
246
249
  if (group !== "records" && !RECORD_ALIAS_GROUPS.includes(group))
247
250
  await startDrainIfDue(program.opts());
251
+ if (action.name() !== "telemetry" && isPartCheckStale())
252
+ startTelemetryReconcile({ nustackUrl: program.opts().nustackUrl });
248
253
  });
249
254
  const docs = program.command("docs").description("Work with a Project's Documents");
250
255
  docs
@@ -6,6 +6,7 @@ import { existsSync } from "node:fs";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { nustackDir } from "./nustack_store.js";
9
+ import { FILEBEAT_SERVICE_LABEL_VARIABLE } from "./telemetry/environment.js";
9
10
  const FILEBEAT_VERSION = process.env.NUSTACK_FILEBEAT_VERSION || "8.15.3";
10
11
  export function desiredFilebeatVersion() {
11
12
  return FILEBEAT_VERSION;
@@ -106,7 +107,10 @@ export function observedFilebeatVersion() {
106
107
  return null;
107
108
  }
108
109
  }
109
- const SERVICE_LABEL = "co.nurix.nustack.filebeat";
110
+ export const SERVICE_LABEL = (() => {
111
+ const override = process.env[FILEBEAT_SERVICE_LABEL_VARIABLE]?.trim() ?? "";
112
+ return /^[a-z0-9][a-z0-9.-]{2,127}$/.test(override) ? override : "co.nurix.nustack.filebeat";
113
+ })();
110
114
  function run(cmd, args) {
111
115
  try {
112
116
  execFileSync(cmd, args, { stdio: "ignore" });
@@ -21,8 +21,10 @@ const KEYS_PLANE = "the credential plane (/api/v1/keys)";
21
21
  export function apiError(payload, status, label, plane) {
22
22
  if (status === 401 && !envToken())
23
23
  return Object.assign(new Error(deadCredentialCopy("revoked")), { code: "SIGNED_OUT" });
24
- const code = typeof payload?.error === "object" && typeof payload.error?.code === "string" ? payload.error.code : "UNEXPECTED";
25
- return Object.assign(new Error(errorMessage(payload, status, label, plane)), { code });
24
+ const envelope = typeof payload?.error === "object" ? payload.error : undefined;
25
+ const code = typeof envelope?.code === "string" ? envelope.code : "UNEXPECTED";
26
+ const details = envelope?.details && typeof envelope.details === "object" && !Array.isArray(envelope.details) ? envelope.details : undefined;
27
+ return Object.assign(new Error(errorMessage(payload, status, label, plane)), details ? { code, details } : { code });
26
28
  }
27
29
  export function errorMessage(payload, status, label, plane) {
28
30
  if (plane && status === 404 && typeof payload?.error === "string") {
@@ -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 = "claude-sessions";
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 brokerFailure = null;
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
- brokerFailure = error instanceof Error ? error.message : String(error);
133
+ brokerError = error;
133
134
  return null;
134
135
  })
135
136
  : null;
136
- if (!credential?.url && !localOutput) {
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?.url) {
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: credential.url, apiKey: credential.key, index }),
165
+ contents: renderCentralConfig({ elkUrl: candidate.url, apiKey: credential.key, index: candidate.index }),
147
166
  };
148
- const centralApply = await applyConfigChange({
149
- files: [central],
150
- scope: "central",
151
- context: centralReloadContext(seams, filebeatDir()),
152
- });
153
- if (!centralApply.ok) {
154
- recordDegraded(store, centralApply.errorCode);
155
- await reportBestEffort(baseUrl, store, seams, accessToken);
156
- return;
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
- const inputs = renderInputs(store, { id: binding.id, binding, enabled: true }, deviceRef, userId) ?? "";
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 inputs = renderInputs(store, { id: bindingId, enabled: false }, device?.deviceRef ?? "", device?.activeUserId ?? "");
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
- for (const row of listActivityBindings(store, bindingId)) {
270
- upsertActivityBinding(store, {
271
- id: row.id,
272
- bindingId: row.bindingId,
273
- provider: row.provider,
274
- providerProjectPath: row.providerProjectPath,
275
- pathFingerprint: row.pathFingerprint,
276
- state: "disabled",
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) => ({
@@ -0,0 +1,2 @@
1
+ export const TELEMETRY_CHILD_VARIABLE = "NUSTACK_TELEMETRY_CHILD";
2
+ export const FILEBEAT_SERVICE_LABEL_VARIABLE = "NUSTACK_FILEBEAT_SERVICE_LABEL";
@@ -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
- for (const binding of listActiveBindings(store)) {
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/nustack",
3
- "version": "0.23.7",
3
+ "version": "0.23.9",
4
4
  "description": "The nustack CLI — bootstrap NuStack services into a repo via the NuStack discovery plane.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -35,8 +35,8 @@
35
35
  "@types/node": "^22.19.20",
36
36
  "typescript": "^5.7.2",
37
37
  "vitest": "^4.1.11",
38
- "@nurix/contracts": "2.0.1",
39
38
  "@nurix/nustack.studio": "0.0.0",
39
+ "@nurix/contracts": "2.0.1",
40
40
  "@nurix/nustack.ui": "0.0.0"
41
41
  },
42
42
  "publishConfig": {