@nurix/nustack 0.10.0-dev.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/README.md +77 -0
- package/dist/commands/add.js +130 -0
- package/dist/commands/agent.js +100 -0
- package/dist/commands/bootstrap.js +54 -0
- package/dist/commands/describe.js +34 -0
- package/dist/commands/device.js +159 -0
- package/dist/commands/docs.js +119 -0
- package/dist/commands/doctor.js +284 -0
- package/dist/commands/graph.js +34 -0
- package/dist/commands/handoff.js +108 -0
- package/dist/commands/init.js +280 -0
- package/dist/commands/lane.js +12 -0
- package/dist/commands/list.js +20 -0
- package/dist/commands/login.js +134 -0
- package/dist/commands/logout.js +44 -0
- package/dist/commands/open.js +54 -0
- package/dist/commands/organization.js +114 -0
- package/dist/commands/review.js +152 -0
- package/dist/commands/search.js +57 -0
- package/dist/commands/sessions.js +62 -0
- package/dist/commands/sync.js +85 -0
- package/dist/commands/telemetry.js +112 -0
- package/dist/commands/toolkit.js +167 -0
- package/dist/commands/validate.js +67 -0
- package/dist/commands/workspace-sync.js +645 -0
- package/dist/commands/workspace.js +479 -0
- package/dist/index.js +357 -0
- package/dist/lib/agent/claudeHooks.js +77 -0
- package/dist/lib/auth_grant.js +398 -0
- package/dist/lib/auth_recovery.js +49 -0
- package/dist/lib/browser_opener.js +24 -0
- package/dist/lib/device_identity.js +28 -0
- package/dist/lib/device_keychain.js +101 -0
- package/dist/lib/env_file.js +75 -0
- package/dist/lib/etna/manager.js +43 -0
- package/dist/lib/etna/profile.js +41 -0
- package/dist/lib/etna/reconcile.js +77 -0
- package/dist/lib/filebeat_installer.js +246 -0
- package/dist/lib/git-sync/core/commit_range.js +37 -0
- package/dist/lib/git-sync/core/engine.js +263 -0
- package/dist/lib/git-sync/core/git_runner.js +137 -0
- package/dist/lib/git-sync/core/operation_state.js +22 -0
- package/dist/lib/git-sync/core/path_normalizer.js +98 -0
- package/dist/lib/git-sync/core/reconciliation_lock.js +103 -0
- package/dist/lib/git-sync/core/repository.js +49 -0
- package/dist/lib/git-sync/core/watch_stream.js +86 -0
- package/dist/lib/git-sync/core/watcher.js +61 -0
- package/dist/lib/git-sync/inbound/applier.js +131 -0
- package/dist/lib/git-sync/inbound/apply_commit.js +225 -0
- package/dist/lib/git-sync/inbound/approval.js +117 -0
- package/dist/lib/git-sync/inbound/content_decision.js +148 -0
- package/dist/lib/git-sync/inbound/download.js +223 -0
- package/dist/lib/git-sync/inbound/event_sync.js +133 -0
- package/dist/lib/git-sync/inbound/index.js +10 -0
- package/dist/lib/git-sync/inbound/notification.js +39 -0
- package/dist/lib/git-sync/inbound/state_fingerprint.js +32 -0
- package/dist/lib/git-sync/inbound/technical_wait.js +39 -0
- package/dist/lib/git-sync/inbound/undo.js +198 -0
- package/dist/lib/git-sync/outbound/extraction.js +107 -0
- package/dist/lib/git-sync/outbound/history_decision.js +112 -0
- package/dist/lib/git-sync/outbound/index.js +26 -0
- package/dist/lib/git-sync/outbound/manifest_builder.js +58 -0
- package/dist/lib/git-sync/outbound/publisher.js +51 -0
- package/dist/lib/git-sync/outbound/queue.js +123 -0
- package/dist/lib/git-sync/outbound/state.js +12 -0
- package/dist/lib/git-sync/outbound/upload_client.js +141 -0
- package/dist/lib/git-sync/recovery/bundle.js +56 -0
- package/dist/lib/git-sync/recovery/crypto.js +114 -0
- package/dist/lib/git-sync/recovery/index.js +3 -0
- package/dist/lib/git-sync/recovery/store.js +55 -0
- package/dist/lib/git-sync/whitelist/index.js +4 -0
- package/dist/lib/git-sync/whitelist/matcher.js +62 -0
- package/dist/lib/git-sync/whitelist/store.js +99 -0
- package/dist/lib/git-sync/whitelist/types.js +46 -0
- package/dist/lib/git-sync/whitelist/validation.js +60 -0
- package/dist/lib/graph/answer.js +171 -0
- package/dist/lib/graph/freshness.js +29 -0
- package/dist/lib/graph/library.js +19 -0
- package/dist/lib/guards.js +106 -0
- package/dist/lib/interactive.js +12 -0
- package/dist/lib/json_output.js +8 -0
- package/dist/lib/lanes/claudeAdapter.js +135 -0
- package/dist/lib/lanes/frames.js +73 -0
- package/dist/lib/lanes/supervisor.js +247 -0
- package/dist/lib/lanes/worktree.js +33 -0
- package/dist/lib/local_store/accepted_uploads.js +49 -0
- package/dist/lib/local_store/bindings.js +163 -0
- package/dist/lib/local_store/commit_origins.js +25 -0
- package/dist/lib/local_store/database.js +71 -0
- package/dist/lib/local_store/device_state.js +72 -0
- package/dist/lib/local_store/filebeat_state.js +133 -0
- package/dist/lib/local_store/handoff_applications.js +166 -0
- package/dist/lib/local_store/history_decisions.js +48 -0
- package/dist/lib/local_store/index.js +14 -0
- package/dist/lib/local_store/migrations.js +240 -0
- package/dist/lib/local_store/paths.js +24 -0
- package/dist/lib/local_store/pending_uploads.js +76 -0
- package/dist/lib/local_store/recovery_bundles.js +66 -0
- package/dist/lib/local_store/sync_vocabulary.js +33 -0
- package/dist/lib/local_store/workspace_events_cache.js +54 -0
- package/dist/lib/loopback.js +78 -0
- package/dist/lib/manifest.js +83 -0
- package/dist/lib/nustack_client.js +547 -0
- package/dist/lib/nustack_store.js +17 -0
- package/dist/lib/onboarding/attach_existing_git.js +65 -0
- package/dist/lib/onboarding/binding.js +91 -0
- package/dist/lib/onboarding/folder_state.js +99 -0
- package/dist/lib/onboarding/git_init.js +10 -0
- package/dist/lib/onboarding/handoff_apply.js +68 -0
- package/dist/lib/onboarding/init_fresh_folder.js +100 -0
- package/dist/lib/onboarding/manifest_writer.js +161 -0
- package/dist/lib/onboarding/web_first_attach.js +133 -0
- package/dist/lib/package_manager.js +23 -0
- package/dist/lib/review/client.js +66 -0
- package/dist/lib/schema_validator.js +24 -0
- package/dist/lib/secure_file.js +10 -0
- package/dist/lib/session_telemetry.js +263 -0
- package/dist/lib/sessions/client.js +48 -0
- package/dist/lib/sessions/outsideSessions.js +54 -0
- package/dist/lib/sse_framing.js +48 -0
- package/dist/lib/telemetry/config_renderer.js +175 -0
- package/dist/lib/telemetry/health_report.js +173 -0
- package/dist/lib/telemetry/provider_paths.js +38 -0
- package/dist/lib/telemetry/redaction_policy.js +90 -0
- package/dist/lib/telemetry/reload.js +84 -0
- package/dist/lib/workspace_context.js +121 -0
- package/dist/spec_schema.json +422 -0
- package/dist/wire/auth.js +1 -0
- package/dist/wire/sync-paths.js +25 -0
- package/dist/wire/telemetry.js +1 -0
- package/package.json +49 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { assertSafePackageName } from "./guards.js";
|
|
5
|
+
export function detectPackageManager(cwd) {
|
|
6
|
+
if (existsSync(path.join(cwd, "pnpm-lock.yaml")))
|
|
7
|
+
return "pnpm";
|
|
8
|
+
if (existsSync(path.join(cwd, "yarn.lock")))
|
|
9
|
+
return "yarn";
|
|
10
|
+
return "npm";
|
|
11
|
+
}
|
|
12
|
+
export async function installPackage(manager, packageName, cwd) {
|
|
13
|
+
const safeName = assertSafePackageName(packageName);
|
|
14
|
+
const verb = manager === "npm" ? "install" : "add";
|
|
15
|
+
const args = [verb, "--", safeName];
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const child = spawn(manager, args, { cwd, stdio: "inherit" });
|
|
18
|
+
child.on("error", reject);
|
|
19
|
+
child.on("exit", (code) => code === 0
|
|
20
|
+
? resolve()
|
|
21
|
+
: reject(new Error(`${manager} ${args.join(" ")} exited with code ${code}`)));
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
2
|
+
export class ReviewUnreachableError extends Error {
|
|
3
|
+
constructor(message) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = "ReviewUnreachableError";
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
function errorMessage(payload, status, label) {
|
|
9
|
+
const enveloped = typeof payload?.error === "object" ? payload.error?.message : undefined;
|
|
10
|
+
return enveloped ?? `${label}: HTTP ${status}`;
|
|
11
|
+
}
|
|
12
|
+
export function makeReviewClient(baseUrl, accessToken) {
|
|
13
|
+
const headers = {
|
|
14
|
+
Authorization: `Bearer ${accessToken}`,
|
|
15
|
+
"Content-Type": "application/json",
|
|
16
|
+
};
|
|
17
|
+
async function call(path, init, label) {
|
|
18
|
+
let response;
|
|
19
|
+
try {
|
|
20
|
+
response = await globalThis.fetch(`${baseUrl}${path}`, {
|
|
21
|
+
...init,
|
|
22
|
+
headers,
|
|
23
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
throw new ReviewUnreachableError(error instanceof Error ? error.message : String(error));
|
|
28
|
+
}
|
|
29
|
+
const payload = (await response.json().catch(() => null));
|
|
30
|
+
if (!response.ok) {
|
|
31
|
+
if (response.status === 401 || response.status === 403) {
|
|
32
|
+
throw new ReviewUnreachableError(errorMessage(payload, response.status, label));
|
|
33
|
+
}
|
|
34
|
+
throw new Error(errorMessage(payload, response.status, label));
|
|
35
|
+
}
|
|
36
|
+
return payload?.data ?? null;
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
async createReviewItem(input) {
|
|
40
|
+
const data = await call("/api/v2/review", {
|
|
41
|
+
method: "POST",
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
kind: "blocking_approval",
|
|
44
|
+
workspaceId: input.workspaceId,
|
|
45
|
+
sessionId: input.sessionId,
|
|
46
|
+
subject: input.subject,
|
|
47
|
+
...(input.answerableByUserId ? { answerableByUserId: input.answerableByUserId } : {}),
|
|
48
|
+
...(input.answer ? { answer: input.answer } : {}),
|
|
49
|
+
}),
|
|
50
|
+
}, "review mirror");
|
|
51
|
+
return data;
|
|
52
|
+
},
|
|
53
|
+
async answerReviewItem(id, verdict) {
|
|
54
|
+
const data = await call(`/api/v2/review/${encodeURIComponent(id)}/answer`, { method: "POST", body: JSON.stringify({ verdict }) }, "review answer");
|
|
55
|
+
return data;
|
|
56
|
+
},
|
|
57
|
+
async readDecisions(commit, repositoryUrl) {
|
|
58
|
+
const query = new URLSearchParams({ commit });
|
|
59
|
+
if (repositoryUrl)
|
|
60
|
+
query.set("repositoryUrl", repositoryUrl);
|
|
61
|
+
const data = await call(`/api/v2/review/decisions?${query.toString()}`, { method: "GET" }, "review check");
|
|
62
|
+
const items = data?.items;
|
|
63
|
+
return Array.isArray(items) ? items : [];
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { Ajv } from "ajv";
|
|
3
|
+
import addFormatsImport from "ajv-formats";
|
|
4
|
+
const addFormats = addFormatsImport;
|
|
5
|
+
export async function loadSchema(override) {
|
|
6
|
+
if (override?.startsWith("http://") || override?.startsWith("https://")) {
|
|
7
|
+
const response = await fetch(override, { signal: AbortSignal.timeout(10_000) });
|
|
8
|
+
if (!response.ok)
|
|
9
|
+
throw new Error(`schema fetch failed: HTTP ${response.status}`);
|
|
10
|
+
return (await response.json());
|
|
11
|
+
}
|
|
12
|
+
const schemaUrl = override
|
|
13
|
+
? new URL(override, `file://${process.cwd()}/`)
|
|
14
|
+
: new URL("../spec_schema.json", import.meta.url);
|
|
15
|
+
return JSON.parse(await readFile(schemaUrl, "utf8"));
|
|
16
|
+
}
|
|
17
|
+
export function validateManifest(schema, manifest) {
|
|
18
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
19
|
+
addFormats(ajv);
|
|
20
|
+
const validate = ajv.compile(schema);
|
|
21
|
+
const isValid = validate(manifest);
|
|
22
|
+
const errors = (validate.errors ?? []).map((error) => `${error.instancePath || "/"} ${error.message ?? "invalid"}`);
|
|
23
|
+
return { isValid, errors };
|
|
24
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
const OWNER_ONLY_FILE = 0o600;
|
|
3
|
+
const OWNER_ONLY_DIR = 0o700;
|
|
4
|
+
export async function writeFileSecure(filePath, data) {
|
|
5
|
+
await writeFile(filePath, data, { encoding: "utf8", mode: OWNER_ONLY_FILE });
|
|
6
|
+
await chmod(filePath, OWNER_ONLY_FILE).catch(() => { });
|
|
7
|
+
}
|
|
8
|
+
export async function mkdirSecure(dirPath) {
|
|
9
|
+
await mkdir(dirPath, { recursive: true, mode: OWNER_ONLY_DIR });
|
|
10
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ensureAccessToken } from "./auth_grant.js";
|
|
6
|
+
import { fetchBrokeredCredential } from "./nustack_client.js";
|
|
7
|
+
import { readLegacyDeviceRecord } from "./nustack_store.js";
|
|
8
|
+
import { openLocalStore } from "./local_store/database.js";
|
|
9
|
+
import { readDeviceState } from "./local_store/device_state.js";
|
|
10
|
+
import { listActiveBindings } from "./local_store/bindings.js";
|
|
11
|
+
import { listActivityBindings, readFilebeatState, upsertActivityBinding, upsertFilebeatState, } from "./local_store/filebeat_state.js";
|
|
12
|
+
import { ensureFilebeatBinary, filebeatBinPath, filebeatDir, desiredFilebeatVersion, observedFilebeatVersion, probeServiceRegistered, registerAndStartService, restartService, testFilebeatConfig, } from "./filebeat_installer.js";
|
|
13
|
+
import { renderBindingInput, renderCentralConfig, renderCodexSharedInput, } from "./telemetry/config_renderer.js";
|
|
14
|
+
import { applyConfigChange, removeInputFile } from "./telemetry/reload.js";
|
|
15
|
+
import { discoverProviderBindings } from "./telemetry/provider_paths.js";
|
|
16
|
+
import { reportTelemetryHealth } from "./telemetry/health_report.js";
|
|
17
|
+
const TELEMETRY_SERVICE = "elastic-observability";
|
|
18
|
+
const DEFAULT_INDEX = "claude-sessions";
|
|
19
|
+
const NO_OP_LOG = () => { };
|
|
20
|
+
function defaultSeams() {
|
|
21
|
+
return {
|
|
22
|
+
ensureBinary: (log) => ensureFilebeatBinary(log),
|
|
23
|
+
reloadContext: (configDir) => ({
|
|
24
|
+
configDir,
|
|
25
|
+
testConfig: (ctx) => testFilebeatConfig(ctx),
|
|
26
|
+
restartService: () => ({ ok: true }),
|
|
27
|
+
}),
|
|
28
|
+
registerService: () => registerAndStartService(),
|
|
29
|
+
serviceRegistered: () => probeServiceRegistered(),
|
|
30
|
+
restartService: () => restartService(),
|
|
31
|
+
fetchImpl: fetch,
|
|
32
|
+
ensureAccessToken: (baseUrl) => ensureAccessToken(baseUrl),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function centralReloadContext(seams, configDir) {
|
|
36
|
+
const base = seams.reloadContext(configDir);
|
|
37
|
+
return { ...base, restartService: async () => ({ ok: await seams.restartService() }) };
|
|
38
|
+
}
|
|
39
|
+
function optedOutByEnv() {
|
|
40
|
+
return process.env.NUSTACK_TELEMETRY === "off";
|
|
41
|
+
}
|
|
42
|
+
function toTelemetryBinding(binding, deviceRef, userId) {
|
|
43
|
+
return {
|
|
44
|
+
bindingId: binding.id,
|
|
45
|
+
repoRoot: binding.repositoryRoot,
|
|
46
|
+
organizationId: binding.organizationId,
|
|
47
|
+
workspaceId: binding.workspaceId,
|
|
48
|
+
deviceRef,
|
|
49
|
+
userId,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function activityRowId(bindingId, provider, pathFingerprint) {
|
|
53
|
+
return `act_${createHash("sha256").update(`${bindingId}:${provider}:${pathFingerprint}`).digest("hex").slice(0, 24)}`;
|
|
54
|
+
}
|
|
55
|
+
async function resolveStore(injected) {
|
|
56
|
+
if (injected)
|
|
57
|
+
return { store: injected, close: () => { } };
|
|
58
|
+
const store = await openLocalStore();
|
|
59
|
+
return { store, close: () => store.close() };
|
|
60
|
+
}
|
|
61
|
+
export async function ensureDeviceTelemetry(opts) {
|
|
62
|
+
if (optedOutByEnv())
|
|
63
|
+
return;
|
|
64
|
+
const log = opts.log ?? NO_OP_LOG;
|
|
65
|
+
const seams = { ...defaultSeams(), ...opts.seams };
|
|
66
|
+
let handle = null;
|
|
67
|
+
try {
|
|
68
|
+
handle = await resolveStore(opts.store);
|
|
69
|
+
const { store } = handle;
|
|
70
|
+
const firstRun = readFilebeatState(store) === null;
|
|
71
|
+
let telemetryEnabled = true;
|
|
72
|
+
if (firstRun) {
|
|
73
|
+
const legacy = await readLegacyDeviceRecord();
|
|
74
|
+
if (legacy?.telemetry?.enabled === false)
|
|
75
|
+
telemetryEnabled = false;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
telemetryEnabled = readFilebeatState(store).telemetryEnabled;
|
|
79
|
+
}
|
|
80
|
+
await seams.ensureBinary(log);
|
|
81
|
+
const observed = observedFilebeatVersion();
|
|
82
|
+
upsertFilebeatState(store, {
|
|
83
|
+
binaryPath: existsSync(filebeatBinPath()) ? filebeatBinPath() : null,
|
|
84
|
+
configDir: filebeatDir(),
|
|
85
|
+
desiredVersion: desiredFilebeatVersion(),
|
|
86
|
+
observedVersion: observed,
|
|
87
|
+
telemetryEnabled,
|
|
88
|
+
lastErrorCode: "no_output_credential",
|
|
89
|
+
});
|
|
90
|
+
await reportBestEffort(opts.baseUrl, store, seams);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
recordDegraded(handle?.store, "device_bootstrap_failed");
|
|
94
|
+
log(`Telemetry bootstrap skipped (non-blocking): ${message(error)}`);
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
handle?.close();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export async function ensureBindingTelemetry(opts) {
|
|
101
|
+
if (optedOutByEnv())
|
|
102
|
+
return;
|
|
103
|
+
const seams = { ...defaultSeams(), ...opts.seams };
|
|
104
|
+
const { store, binding, baseUrl } = opts;
|
|
105
|
+
try {
|
|
106
|
+
const state = readFilebeatState(store);
|
|
107
|
+
if (state && !state.telemetryEnabled)
|
|
108
|
+
return;
|
|
109
|
+
const device = readDeviceState(store);
|
|
110
|
+
const deviceRef = device?.deviceRef ?? "";
|
|
111
|
+
const userId = device?.activeUserId ?? "";
|
|
112
|
+
const auth = await seams.ensureAccessToken(baseUrl);
|
|
113
|
+
if (!auth) {
|
|
114
|
+
recordDegraded(store, "no_device_token");
|
|
115
|
+
await reportBestEffort(baseUrl, store, seams, null);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (!binding.telemetryEnabled) {
|
|
119
|
+
await removeBindingTelemetry({ store, bindingId: binding.id, seams: opts.seams });
|
|
120
|
+
await reportBestEffort(baseUrl, store, seams, auth.accessToken);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
let brokerFailure = null;
|
|
124
|
+
const credential = await fetchBrokeredCredential(baseUrl, auth.accessToken, {
|
|
125
|
+
workspaceId: binding.workspaceId,
|
|
126
|
+
service: TELEMETRY_SERVICE,
|
|
127
|
+
}).catch((error) => {
|
|
128
|
+
brokerFailure = error instanceof Error ? error.message : String(error);
|
|
129
|
+
return null;
|
|
130
|
+
});
|
|
131
|
+
if (!credential?.url) {
|
|
132
|
+
const unserved = brokerFailure !== null && brokerFailure.includes("does not serve");
|
|
133
|
+
recordDegraded(store, unserved ? "credential_plane_unserved" : "no_output_credential");
|
|
134
|
+
await reportBestEffort(baseUrl, store, seams, auth.accessToken);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const index = credential.meta?.index || DEFAULT_INDEX;
|
|
138
|
+
const central = {
|
|
139
|
+
relativePath: "filebeat.yml",
|
|
140
|
+
contents: renderCentralConfig({ elkUrl: credential.url, apiKey: credential.key, index }),
|
|
141
|
+
};
|
|
142
|
+
const centralApply = await applyConfigChange({
|
|
143
|
+
files: [central],
|
|
144
|
+
scope: "central",
|
|
145
|
+
context: centralReloadContext(seams, filebeatDir()),
|
|
146
|
+
});
|
|
147
|
+
if (!centralApply.ok) {
|
|
148
|
+
recordDegraded(store, centralApply.errorCode);
|
|
149
|
+
await reportBestEffort(baseUrl, store, seams, auth.accessToken);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (!seams.serviceRegistered())
|
|
153
|
+
await seams.registerService();
|
|
154
|
+
const bindings = activeBindingSet(store, binding, deviceRef, userId);
|
|
155
|
+
const inputs = [
|
|
156
|
+
{ relativePath: `inputs.d/${binding.id}.yml`, contents: renderBindingInput(toTelemetryBinding(binding, deviceRef, userId)) },
|
|
157
|
+
{ relativePath: "inputs.d/codex-shared.yml", contents: renderCodexSharedInput(bindings) },
|
|
158
|
+
];
|
|
159
|
+
await removeOrphanInputs(bindings);
|
|
160
|
+
const inputsApply = await applyConfigChange({
|
|
161
|
+
files: inputs,
|
|
162
|
+
scope: "inputs",
|
|
163
|
+
context: seams.reloadContext(filebeatDir()),
|
|
164
|
+
});
|
|
165
|
+
if (!inputsApply.ok) {
|
|
166
|
+
recordDegraded(store, inputsApply.errorCode);
|
|
167
|
+
await reportBestEffort(baseUrl, store, seams, auth.accessToken);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
for (const discovered of discoverProviderBindings(binding.repositoryRoot)) {
|
|
171
|
+
upsertActivityBinding(store, {
|
|
172
|
+
id: activityRowId(binding.id, discovered.provider, discovered.pathFingerprint),
|
|
173
|
+
bindingId: binding.id,
|
|
174
|
+
provider: discovered.provider,
|
|
175
|
+
providerProjectPath: discovered.absolutePath,
|
|
176
|
+
pathFingerprint: discovered.pathFingerprint,
|
|
177
|
+
state: discovered.state,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
upsertFilebeatState(store, {
|
|
181
|
+
binaryPath: existsSync(filebeatBinPath()) ? filebeatBinPath() : null,
|
|
182
|
+
configDir: filebeatDir(),
|
|
183
|
+
desiredVersion: desiredFilebeatVersion(),
|
|
184
|
+
observedVersion: observedFilebeatVersion(),
|
|
185
|
+
processState: "running",
|
|
186
|
+
lastErrorCode: null,
|
|
187
|
+
});
|
|
188
|
+
await reportBestEffort(baseUrl, store, seams, auth.accessToken);
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
recordDegraded(store, "binding_reconcile_failed");
|
|
192
|
+
void message(error);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
export async function removeBindingTelemetry(opts) {
|
|
196
|
+
const seams = { ...defaultSeams(), ...opts.seams };
|
|
197
|
+
const { store, bindingId } = opts;
|
|
198
|
+
try {
|
|
199
|
+
await removeInputFile(filebeatDir(), `inputs.d/${bindingId}.yml`);
|
|
200
|
+
const device = readDeviceState(store);
|
|
201
|
+
const remaining = listActiveBindings(store)
|
|
202
|
+
.filter((b) => b.id !== bindingId)
|
|
203
|
+
.map((b) => toTelemetryBinding(b, device?.deviceRef ?? "", device?.activeUserId ?? ""));
|
|
204
|
+
if (remaining.length === 0) {
|
|
205
|
+
await removeInputFile(filebeatDir(), "inputs.d/codex-shared.yml");
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
await applyConfigChange({
|
|
209
|
+
files: [{ relativePath: "inputs.d/codex-shared.yml", contents: renderCodexSharedInput(remaining) }],
|
|
210
|
+
scope: "inputs",
|
|
211
|
+
context: seams.reloadContext(filebeatDir()),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
for (const row of listActivityBindings(store, bindingId)) {
|
|
215
|
+
upsertActivityBinding(store, {
|
|
216
|
+
id: row.id,
|
|
217
|
+
bindingId: row.bindingId,
|
|
218
|
+
provider: row.provider,
|
|
219
|
+
providerProjectPath: row.providerProjectPath,
|
|
220
|
+
pathFingerprint: row.pathFingerprint,
|
|
221
|
+
state: "disabled",
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function activeBindingSet(store, binding, deviceRef, userId) {
|
|
229
|
+
const byId = new Map();
|
|
230
|
+
for (const b of listActiveBindings(store)) {
|
|
231
|
+
if (b.telemetryEnabled)
|
|
232
|
+
byId.set(b.id, toTelemetryBinding(b, deviceRef, userId));
|
|
233
|
+
}
|
|
234
|
+
byId.set(binding.id, toTelemetryBinding(binding, deviceRef, userId));
|
|
235
|
+
return [...byId.values()];
|
|
236
|
+
}
|
|
237
|
+
async function removeOrphanInputs(bindings) {
|
|
238
|
+
const inputsDirPath = path.join(filebeatDir(), "inputs.d");
|
|
239
|
+
if (!existsSync(inputsDirPath))
|
|
240
|
+
return;
|
|
241
|
+
const keep = new Set(["codex-shared.yml", ...bindings.map((b) => `${b.bindingId}.yml`)]);
|
|
242
|
+
for (const name of await readdir(inputsDirPath)) {
|
|
243
|
+
if (name.endsWith(".yml") && !keep.has(name)) {
|
|
244
|
+
await removeInputFile(filebeatDir(), `inputs.d/${name}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function recordDegraded(store, errorCode) {
|
|
249
|
+
if (!store)
|
|
250
|
+
return;
|
|
251
|
+
try {
|
|
252
|
+
upsertFilebeatState(store, { lastErrorCode: errorCode });
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
async function reportBestEffort(baseUrl, store, seams, accessToken) {
|
|
258
|
+
const token = accessToken ?? (await seams.ensureAccessToken(baseUrl).catch(() => null))?.accessToken ?? null;
|
|
259
|
+
await reportTelemetryHealth({ baseUrl, store, accessToken: token, fetchImpl: seams.fetchImpl });
|
|
260
|
+
}
|
|
261
|
+
function message(error) {
|
|
262
|
+
return error instanceof Error ? error.message : String(error);
|
|
263
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { assertSafeOpaqueId } from "../guards.js";
|
|
2
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
3
|
+
function errorMessage(payload, status, label) {
|
|
4
|
+
const enveloped = typeof payload?.error === "object" ? payload.error?.message : undefined;
|
|
5
|
+
return enveloped ?? `${label}: HTTP ${status}`;
|
|
6
|
+
}
|
|
7
|
+
export function makeSessionsClient(baseUrl, accessToken) {
|
|
8
|
+
const headers = {
|
|
9
|
+
Authorization: `Bearer ${accessToken}`,
|
|
10
|
+
"Content-Type": "application/json",
|
|
11
|
+
};
|
|
12
|
+
async function post(path, body, label) {
|
|
13
|
+
const response = await globalThis.fetch(`${baseUrl}${path}`, {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers,
|
|
16
|
+
body: JSON.stringify(body),
|
|
17
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
18
|
+
});
|
|
19
|
+
const payload = (await response.json().catch(() => null));
|
|
20
|
+
if (!response.ok || !payload?.data?.id) {
|
|
21
|
+
throw new Error(errorMessage(payload, response.status, label));
|
|
22
|
+
}
|
|
23
|
+
return { status: response.status, session: payload.data };
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
async createSession(workspaceId, input) {
|
|
27
|
+
const safeId = assertSafeOpaqueId("The Workspace id", workspaceId);
|
|
28
|
+
const answered = await post(`/api/v2/workspaces/${encodeURIComponent(safeId)}/sessions`, input, "session create failed");
|
|
29
|
+
return { created: answered.status === 201, session: answered.session };
|
|
30
|
+
},
|
|
31
|
+
async transitionSession(workspaceId, sessionId, to, patch = {}) {
|
|
32
|
+
const safeWorkspace = assertSafeOpaqueId("The Workspace id", workspaceId);
|
|
33
|
+
const safeSession = assertSafeOpaqueId("The session id", sessionId);
|
|
34
|
+
const answered = await post(`/api/v2/workspaces/${encodeURIComponent(safeWorkspace)}/sessions/${encodeURIComponent(safeSession)}/transition`, { to, ...patch }, "session transition failed");
|
|
35
|
+
return answered.session;
|
|
36
|
+
},
|
|
37
|
+
async setVendorSessionId(workspaceId, sessionId, vendorSessionId) {
|
|
38
|
+
return this.transitionSession(workspaceId, sessionId, "running", { vendorSessionId });
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function sessionsClientFromEnv(env = process.env) {
|
|
43
|
+
const baseUrl = env.NUSTACK_API_BASE_URL?.trim();
|
|
44
|
+
const token = env.NUSTACK_DEVICE_TOKEN?.trim();
|
|
45
|
+
if (!baseUrl || !token)
|
|
46
|
+
return null;
|
|
47
|
+
return makeSessionsClient(baseUrl.replace(/\/+$/, ""), token);
|
|
48
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export const STOPPED_GROWING_MS = 10 * 60 * 1000;
|
|
2
|
+
function vendorOf(provider) {
|
|
3
|
+
if (provider === "claude_code")
|
|
4
|
+
return { vendor: "claude_code", renderer: "claude_transcript" };
|
|
5
|
+
if (provider === "codex")
|
|
6
|
+
return { vendor: "codex", renderer: "codex_transcript" };
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
const AT_REST = new Set(["done", "failed", "cancelled"]);
|
|
10
|
+
export async function recordOutsideSessions(client, input) {
|
|
11
|
+
const now = input.now ?? Date.now;
|
|
12
|
+
const laneIds = new Set((input.laneSessionIds ?? []).filter((id) => Boolean(id)));
|
|
13
|
+
const report = { created: 0, finished: 0, skipped: 0, failed: 0 };
|
|
14
|
+
for (const transcript of input.transcripts) {
|
|
15
|
+
if (!transcript.workspaceId) {
|
|
16
|
+
report.skipped += 1;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
const kinds = vendorOf(transcript.provider);
|
|
20
|
+
if (!kinds) {
|
|
21
|
+
report.skipped += 1;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const stale = now() - transcript.modifiedMs >= STOPPED_GROWING_MS;
|
|
25
|
+
const vendorSessionId = transcript.vendorSessionId?.trim() || null;
|
|
26
|
+
const isLane = vendorSessionId !== null && laneIds.has(vendorSessionId);
|
|
27
|
+
try {
|
|
28
|
+
const { created, session } = await client.createSession(transcript.workspaceId, {
|
|
29
|
+
id: transcript.sessionKey,
|
|
30
|
+
startedBy: "person",
|
|
31
|
+
substrate: "device",
|
|
32
|
+
mode: "synchronous",
|
|
33
|
+
origin: isLane ? "inside" : "outside",
|
|
34
|
+
vendor: kinds.vendor,
|
|
35
|
+
renderer: kinds.renderer,
|
|
36
|
+
status: stale ? "done" : "running",
|
|
37
|
+
...(vendorSessionId ? { vendorSessionId } : {}),
|
|
38
|
+
});
|
|
39
|
+
if (created)
|
|
40
|
+
report.created += 1;
|
|
41
|
+
if (stale && !AT_REST.has(session.status)) {
|
|
42
|
+
await client.transitionSession(transcript.workspaceId, transcript.sessionKey, "done");
|
|
43
|
+
report.finished += 1;
|
|
44
|
+
}
|
|
45
|
+
else if (!created) {
|
|
46
|
+
report.skipped += 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
report.failed += 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return report;
|
|
54
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export function createSseParser() {
|
|
2
|
+
let buffer = "";
|
|
3
|
+
return {
|
|
4
|
+
push(chunk) {
|
|
5
|
+
buffer += chunk;
|
|
6
|
+
const blocks = [];
|
|
7
|
+
let split = buffer.indexOf("\n\n");
|
|
8
|
+
while (split !== -1) {
|
|
9
|
+
const block = parseBlock(buffer.slice(0, split));
|
|
10
|
+
buffer = buffer.slice(split + 2);
|
|
11
|
+
if (block)
|
|
12
|
+
blocks.push(block);
|
|
13
|
+
split = buffer.indexOf("\n\n");
|
|
14
|
+
}
|
|
15
|
+
return blocks;
|
|
16
|
+
},
|
|
17
|
+
flush() {
|
|
18
|
+
const rest = buffer;
|
|
19
|
+
buffer = "";
|
|
20
|
+
if (rest.trim().length === 0)
|
|
21
|
+
return [];
|
|
22
|
+
const block = parseBlock(rest);
|
|
23
|
+
return block ? [block] : [];
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function parseBlock(raw) {
|
|
28
|
+
let event = "message";
|
|
29
|
+
const data = [];
|
|
30
|
+
for (const line of raw.split("\n")) {
|
|
31
|
+
const cleaned = line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
32
|
+
if (cleaned.startsWith(":"))
|
|
33
|
+
continue;
|
|
34
|
+
const colon = cleaned.indexOf(":");
|
|
35
|
+
if (colon === -1)
|
|
36
|
+
continue;
|
|
37
|
+
const field = cleaned.slice(0, colon);
|
|
38
|
+
const raw_value = cleaned.slice(colon + 1);
|
|
39
|
+
const value = raw_value.startsWith(" ") ? raw_value.slice(1) : raw_value;
|
|
40
|
+
if (field === "event")
|
|
41
|
+
event = value;
|
|
42
|
+
else if (field === "data")
|
|
43
|
+
data.push(value);
|
|
44
|
+
}
|
|
45
|
+
if (data.length === 0)
|
|
46
|
+
return null;
|
|
47
|
+
return { event, data: data.join("\n") };
|
|
48
|
+
}
|