@aixle/insights 0.2.3-staging → 0.2.5-staging
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 +54 -0
- package/dist/auth/credentials.js +24 -8
- package/dist/cli.js +3 -0
- package/dist/hooks/cursor-hooks-mapper.d.ts +1 -1
- package/dist/hooks/cursor-hooks-mapper.js +3 -1
- package/dist/hooks/cursor-hooks-reader.d.ts +10 -1
- package/dist/hooks/cursor-hooks-reader.js +10 -2
- package/dist/lib/config.d.ts +2 -2
- package/dist/lib/config.js +28 -20
- package/dist/lib/parse-error.d.ts +21 -0
- package/dist/lib/parse-error.js +25 -0
- package/dist/lib/project-resolver.d.ts +8 -0
- package/dist/lib/project-resolver.js +18 -13
- package/dist/readers/claude.d.ts +2 -0
- package/dist/readers/claude.js +3 -1
- package/dist/readers/cursor.d.ts +1 -0
- package/dist/server.js +3 -1
- package/dist/state.js +38 -35
- package/dist/sync.d.ts +8 -0
- package/dist/sync.js +63 -26
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -162,6 +162,12 @@ Optional `~/.aixle-insights/config.json` accepts Cursor line-cost overrides (per
|
|
|
162
162
|
}
|
|
163
163
|
```
|
|
164
164
|
|
|
165
|
+
The file is optional — an absent `config.json` is the normal case and is silent. A file that is
|
|
166
|
+
present but unusable is ignored entirely (every override falls back to its default) and a
|
|
167
|
+
`config_parse_failed` line is written to `mcp.log`. That covers malformed JSON and valid JSON that
|
|
168
|
+
isn't an object, including a **top-level array** — a common mistake when writing per-model rates.
|
|
169
|
+
Nothing is printed to the terminal, so check the log if an override appears to have no effect.
|
|
170
|
+
|
|
165
171
|
## Security
|
|
166
172
|
|
|
167
173
|
`@aixle/insights` enforces HTTPS for any remote host. Plaintext `http://` is allowed only for loopback (`localhost`, `127.0.0.0/8`, `[::1]`) so that local-dev flows against `make up` continue to work without friction.
|
|
@@ -188,6 +194,22 @@ The runtime gate exists because `init`'s two gates only run once, at login time.
|
|
|
188
194
|
|
|
189
195
|
The Keycloak issuer URL (`--keycloak-url` / `KEYCLOAK_ISSUER`) is **not** TLS-gated by this package. Same threat model, different ticket — tracked separately. For now, use HTTPS for any remote Keycloak issuer; the OIDC device-flow library will fail the request if the cert is invalid, but it will not refuse to attempt plaintext.
|
|
190
196
|
|
|
197
|
+
### Local store integrity
|
|
198
|
+
|
|
199
|
+
Transport security covers data in flight. The other half of the threat model is what the package
|
|
200
|
+
reads back off the local machine: credentials (keychain or file), `config.json`, and state files are
|
|
201
|
+
all attacker-writable if the account is compromised, so none of them is trusted on read.
|
|
202
|
+
|
|
203
|
+
Each is validated every time it is loaded. A payload that fails to parse, or that parses but does
|
|
204
|
+
not match the expected shape, is **rejected** and the caller falls back to its documented default —
|
|
205
|
+
no credentials, no config overrides, fresh state. Every rejection is recorded in `mcp.log`, so a
|
|
206
|
+
corrupted or tampered store is distinguishable from one that was never created; before this, both
|
|
207
|
+
were silent and looked identical to a fresh install. See
|
|
208
|
+
[Diagnostics](#diagnostics) for the event names.
|
|
209
|
+
|
|
210
|
+
Log fields carry only the file path (or the keychain service name) and a short machine-readable
|
|
211
|
+
reason. File contents, keychain payloads, and tokens are never logged.
|
|
212
|
+
|
|
191
213
|
## Cursor hook forwarder (opt-in)
|
|
192
214
|
|
|
193
215
|
`aixle-insights init --hooks --tool-name cursor` installs a Node script as a Cursor hook (`~/.cursor/hooks.json`). The script appends redacted hook payloads to `~/.aixle-insights/hooks-queue.ndjson`; the background sync drains the queue on its next cycle and POSTs the events with accurate per-turn model attribution. Requires a Cursor restart after install. To remove, run `aixle-insights uninstall-hooks` and restart Cursor again.
|
|
@@ -225,6 +247,35 @@ aixle-insights verify-hooks # JSON: hooks installed + queue depth
|
|
|
225
247
|
|
|
226
248
|
`mcp.log` (rotates at 5 MiB to `mcp.log.1`) under the app home directory captures operational events. Inside Claude Code, the **`aixle_insights_status`** MCP tool returns the same diagnostic structure as `aixle-insights health`.
|
|
227
249
|
|
|
250
|
+
### Local-store integrity events
|
|
251
|
+
|
|
252
|
+
These four are the only signal that a local store was present but unusable — `health` and
|
|
253
|
+
`aixle_insights_status` do **not** report them, so `mcp.log` is the sole surface:
|
|
254
|
+
|
|
255
|
+
| Event | Fires when |
|
|
256
|
+
|---|---|
|
|
257
|
+
| `credentials_parse_failed` | `credentials.json` exists but was rejected |
|
|
258
|
+
| `credentials_keytar_parse_failed` | the OS keychain entry exists but was rejected |
|
|
259
|
+
| `config_parse_failed` | `config.json` exists but was rejected |
|
|
260
|
+
| `state_parse_failed` | a state file exists but was rejected |
|
|
261
|
+
|
|
262
|
+
Each carries a `reason` distinguishing the two failure modes:
|
|
263
|
+
|
|
264
|
+
- `invalid_json` — the payload did not parse at all.
|
|
265
|
+
- `invalid_shape` — it parsed, but validation rejected it: credentials with no usable token, a
|
|
266
|
+
`config.json` that is a JSON array, a state file missing `version` / `sessions`, and so on.
|
|
267
|
+
|
|
268
|
+
Three properties are worth relying on:
|
|
269
|
+
|
|
270
|
+
- An **absent** file never warns. That is the everyday case (most users never create a
|
|
271
|
+
`config.json`, and every machine starts with no state file), so a warning always means something
|
|
272
|
+
is actually there and wrong.
|
|
273
|
+
- A **missing or disabled OS keychain** never warns either — falling back to the file is expected
|
|
274
|
+
on headless Linux, CI, and containers, not an error.
|
|
275
|
+
- All four are written to the log **only**, never mirrored to stderr, because stray output on the
|
|
276
|
+
stdio transport corrupts the MCP protocol. Emitting a warning never changes the fallback the
|
|
277
|
+
caller returns.
|
|
278
|
+
|
|
228
279
|
## Troubleshooting
|
|
229
280
|
|
|
230
281
|
| Symptom | Most likely cause | Fix |
|
|
@@ -236,6 +287,9 @@ aixle-insights verify-hooks # JSON: hooks installed + queue depth
|
|
|
236
287
|
| `health` shows `authenticated: true` but `last_result` is `sent: 0, failed: N` cycle after cycle | Same as the 401 row above. `authenticated` only proves the OIDC token was acquired, not that the ingest token still validates server-side. | Re-init as above. |
|
|
237
288
|
| `last_result` reports `sent: N` but the Events UI shows nothing | The Temporal worker is not running. The ingest endpoint returns HTTP 202 (queued) regardless of worker state. | `make worker` (or check `docker ps` for `db90-worker`). See [LOCAL-DEV.md](./LOCAL-DEV.md) §1. |
|
|
238
289
|
| `sync_lock_skip {reason: "advisory_lock_held"}` in the log | Another sync cycle is still holding `~/.aixle-insights/state.lock`. | Wait for it to finish; only delete the lock file (`rm -f ~/.aixle-insights/state.lock`) after confirming no `aixle-insights run` process is alive (`pgrep -fa aixle-insights`). |
|
|
290
|
+
| `health` reports `authenticated: false` right after a successful `init`, and `credentials_parse_failed` or `credentials_keytar_parse_failed` is in the log | The credential store exists but was rejected, so it is treated as absent. The `reason` field says whether it failed to parse (`invalid_json`) or parsed into the wrong shape (`invalid_shape`). | Re-run `init`. If you hand-edited `credentials.json` for local testing, remember the keychain is read **first** — see [State + credentials](#state--credentials). |
|
|
291
|
+
| A `config.json` override has no effect, and `config_parse_failed` is in the log | The file is malformed, or is valid JSON that is not an object — a top-level array is the usual mistake. | Fix it to match the shape under [Environment](#environment); until it parses, every override is ignored. |
|
|
292
|
+
| Sync re-sends history that was already delivered, and `state_parse_failed` is in the log | A state file was present but rejected, so sync fell back to fresh state and lost its dedup checkpoints. | This is recovery, not a loop — the next successful cycle writes valid state. Ingest upserts by session, so duplicates are absorbed. Worth investigating what wrote the bad file. |
|
|
239
293
|
| `aixle-insights --help` doesn't list `--insecure` | You're running an older published version of the package, not the local source. | `which aixle-insights` shows the path. To run local source: `cd packages/tools/aixle-insights && npm run build && npm link`. To return to the published version: `npm unlink -g @aixle/insights && npm install -g @aixle/insights@latest`. |
|
|
240
294
|
| Not sure whether `aixle-insights` is a `npm link` or a real install | Real installs are regular files; `npm link` is a symlink chain into the repo. | `readlink "$(which aixle-insights)"` shows the link target if any. A linked install will trace back to a path under your monorepo checkout. |
|
|
241
295
|
|
package/dist/auth/credentials.js
CHANGED
|
@@ -4,6 +4,7 @@ import { userInfo } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { getAppDir } from "../state.js";
|
|
6
6
|
import { mcpLog } from "../log.js";
|
|
7
|
+
import { describeReadFailure } from "../lib/parse-error.js";
|
|
7
8
|
export const KEYTAR_SERVICE = "aixle-insights";
|
|
8
9
|
const KEYTAR_ACCOUNT = "aixle-insights-ingest-credential";
|
|
9
10
|
function credentialsPath(appDir) {
|
|
@@ -62,15 +63,23 @@ export function loadCredentialsFromFileOnly(appDir = getAppDir()) {
|
|
|
62
63
|
const filePath = credentialsPath(appDir);
|
|
63
64
|
if (!existsSync(filePath))
|
|
64
65
|
return null;
|
|
66
|
+
let raw;
|
|
65
67
|
try {
|
|
66
|
-
|
|
67
|
-
return normalizeLoadedCredentials(raw);
|
|
68
|
+
raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
68
69
|
}
|
|
69
70
|
catch (err) {
|
|
70
|
-
// File exists (checked above) but
|
|
71
|
-
mcpLog.warn("credentials_parse_failed", { path: filePath,
|
|
71
|
+
// File exists (checked above) but is not readable/valid JSON — distinguishes tampering from "never created".
|
|
72
|
+
mcpLog.warn("credentials_parse_failed", { path: filePath, ...describeReadFailure(err) }, false);
|
|
72
73
|
return null;
|
|
73
74
|
}
|
|
75
|
+
const normalized = normalizeLoadedCredentials(raw);
|
|
76
|
+
if (normalized === null) {
|
|
77
|
+
// Valid JSON, but not a credential shape we accept. `normalizeLoadedCredentials` signals
|
|
78
|
+
// rejection by returning null and never throws, so this cannot surface in the catch
|
|
79
|
+
// above — without this branch a plausible-looking replacement file stays silent. (DB90DV-699)
|
|
80
|
+
mcpLog.warn("credentials_parse_failed", { path: filePath, reason: "invalid_shape" }, false);
|
|
81
|
+
}
|
|
82
|
+
return normalized;
|
|
74
83
|
}
|
|
75
84
|
async function tryKeytarGet() {
|
|
76
85
|
if (keytarDisabled())
|
|
@@ -86,15 +95,22 @@ async function tryKeytarGet() {
|
|
|
86
95
|
}
|
|
87
96
|
if (!raw)
|
|
88
97
|
return null;
|
|
98
|
+
let parsed;
|
|
89
99
|
try {
|
|
90
|
-
|
|
91
|
-
return normalizeLoadedCredentials(parsed);
|
|
100
|
+
parsed = JSON.parse(raw);
|
|
92
101
|
}
|
|
93
102
|
catch (err) {
|
|
94
|
-
// Keychain entry exists (checked above) but
|
|
95
|
-
mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE,
|
|
103
|
+
// Keychain entry exists (checked above) but is not valid JSON — distinguishes tampering from "no entry".
|
|
104
|
+
mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE, ...describeReadFailure(err) }, false);
|
|
96
105
|
return null;
|
|
97
106
|
}
|
|
107
|
+
const normalized = normalizeLoadedCredentials(parsed);
|
|
108
|
+
if (normalized === null) {
|
|
109
|
+
// Same shape-rejection hole as the file path above. Fields stay service-only — never the
|
|
110
|
+
// keychain payload. (DB90DV-699)
|
|
111
|
+
mcpLog.warn("credentials_keytar_parse_failed", { keytarService: KEYTAR_SERVICE, reason: "invalid_shape" }, false);
|
|
112
|
+
}
|
|
113
|
+
return normalized;
|
|
98
114
|
}
|
|
99
115
|
async function tryKeytarSet(payload) {
|
|
100
116
|
if (keytarDisabled())
|
package/dist/cli.js
CHANGED
|
@@ -377,10 +377,12 @@ export async function runOnce(deps, options) {
|
|
|
377
377
|
const lookupToken = pickProjectLookupToken(creds);
|
|
378
378
|
let projectId = null;
|
|
379
379
|
let projectIdSource = "none";
|
|
380
|
+
let repositoryId = null;
|
|
380
381
|
if (lookupToken) {
|
|
381
382
|
const resolution = await runtime.resolveProjectId(undefined, undefined, creds.host, lookupToken, false, creds.insecureHttpAllowed === true);
|
|
382
383
|
projectId = resolution.projectId;
|
|
383
384
|
projectIdSource = resolution.source;
|
|
385
|
+
repositoryId = resolution.repositoryId;
|
|
384
386
|
mcpLog.info("project_attribution_resolved", { project_id: resolution.projectId, source: resolution.source }, false);
|
|
385
387
|
}
|
|
386
388
|
const result = await runtime.syncTelemetryTools({
|
|
@@ -389,6 +391,7 @@ export async function runOnce(deps, options) {
|
|
|
389
391
|
verbose: false,
|
|
390
392
|
projectId,
|
|
391
393
|
projectIdSource,
|
|
394
|
+
repositoryId,
|
|
392
395
|
projectLookupToken: lookupToken,
|
|
393
396
|
pricing: runtime.pricing,
|
|
394
397
|
cursorPricing: resolveCursorPricing(undefined, appDirRuntime),
|
|
@@ -19,4 +19,4 @@ export declare function warnOnCursorVersion(event: HookLogEvent, verbose: boolea
|
|
|
19
19
|
* Map a validated hook event to the CursorPayload contract.
|
|
20
20
|
* Call shouldIngestHookEvent() before this — it does not re-validate.
|
|
21
21
|
*/
|
|
22
|
-
export declare function mapHookEventToPayload(event: HookLogEvent, projectId?: string | null): CursorPayload;
|
|
22
|
+
export declare function mapHookEventToPayload(event: HookLogEvent, projectId?: string | null, repositoryId?: string | null): CursorPayload;
|
|
@@ -50,7 +50,7 @@ export function warnOnCursorVersion(event, verbose) {
|
|
|
50
50
|
* Map a validated hook event to the CursorPayload contract.
|
|
51
51
|
* Call shouldIngestHookEvent() before this — it does not re-validate.
|
|
52
52
|
*/
|
|
53
|
-
export function mapHookEventToPayload(event, projectId) {
|
|
53
|
+
export function mapHookEventToPayload(event, projectId, repositoryId) {
|
|
54
54
|
const workspace = Array.isArray(event.workspace_roots) && typeof event.workspace_roots[0] === "string"
|
|
55
55
|
? event.workspace_roots[0]
|
|
56
56
|
: "unknown";
|
|
@@ -80,5 +80,7 @@ export function mapHookEventToPayload(event, projectId) {
|
|
|
80
80
|
};
|
|
81
81
|
if (projectId)
|
|
82
82
|
payload.project_id = projectId;
|
|
83
|
+
if (repositoryId)
|
|
84
|
+
payload.repository_id = repositoryId;
|
|
83
85
|
return payload;
|
|
84
86
|
}
|
|
@@ -14,7 +14,16 @@ export interface ProcessHooksQueueParams {
|
|
|
14
14
|
on429: (retryAfter: number, quotaExceeded: boolean) => void;
|
|
15
15
|
/** If true, skip events already in state.sessions. */
|
|
16
16
|
skipSeen?: boolean;
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Resolve project + repository attribution for a workspace path. Returning a
|
|
19
|
+
* repositoryId lets hook events carry the same repository_id the sync path
|
|
20
|
+
* stamps. A null repositoryId (or an older resolver returning only a string)
|
|
21
|
+
* simply omits it.
|
|
22
|
+
*/
|
|
23
|
+
resolveProjectId?: (workspace: string) => Promise<{
|
|
24
|
+
projectId: string | null;
|
|
25
|
+
repositoryId: string | null;
|
|
26
|
+
} | string | null>;
|
|
18
27
|
verbose?: boolean;
|
|
19
28
|
}
|
|
20
29
|
export interface ProcessHooksResult {
|
|
@@ -100,10 +100,18 @@ export async function processHooksQueue(params) {
|
|
|
100
100
|
continue;
|
|
101
101
|
}
|
|
102
102
|
let projectId;
|
|
103
|
+
let repositoryId;
|
|
103
104
|
if (resolveProjectId && workspace) {
|
|
104
|
-
|
|
105
|
+
const resolved = await resolveProjectId(workspace);
|
|
106
|
+
if (typeof resolved === "string" || resolved === null) {
|
|
107
|
+
projectId = resolved;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
projectId = resolved.projectId;
|
|
111
|
+
repositoryId = resolved.repositoryId;
|
|
112
|
+
}
|
|
105
113
|
}
|
|
106
|
-
const payload = mapHookEventToPayload(event, projectId);
|
|
114
|
+
const payload = mapHookEventToPayload(event, projectId, repositoryId);
|
|
107
115
|
const ok = await postEvent(payload, host, token, { on429, allowInsecureHttp });
|
|
108
116
|
if (ok) {
|
|
109
117
|
totalSent++;
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -12,8 +12,8 @@ export interface BaseConfig {
|
|
|
12
12
|
* Load a connector's `config.json` from disk. Returns `{}` on missing or
|
|
13
13
|
* malformed files — callers fall back to env vars / CLI flags / defaults.
|
|
14
14
|
*
|
|
15
|
-
* @param configDir Directory containing `config.json`, typically the
|
|
16
|
-
*
|
|
15
|
+
* @param configDir Directory containing `config.json`, typically the app home directory
|
|
16
|
+
* (`~/.aixle-insights`, or `AIXLE_INSIGHTS_HOME` when set).
|
|
17
17
|
* @param parsePricing Optional callback that extracts a connector-specific
|
|
18
18
|
* pricing shape from the raw parsed JSON. Returns
|
|
19
19
|
* `undefined` when the pricing block is missing or invalid.
|
package/dist/lib/config.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { mcpLog } from "../log.js";
|
|
4
|
+
import { describeReadFailure } from "./parse-error.js";
|
|
4
5
|
/**
|
|
5
6
|
* Load a connector's `config.json` from disk. Returns `{}` on missing or
|
|
6
7
|
* malformed files — callers fall back to env vars / CLI flags / defaults.
|
|
7
8
|
*
|
|
8
|
-
* @param configDir Directory containing `config.json`, typically the
|
|
9
|
-
*
|
|
9
|
+
* @param configDir Directory containing `config.json`, typically the app home directory
|
|
10
|
+
* (`~/.aixle-insights`, or `AIXLE_INSIGHTS_HOME` when set).
|
|
10
11
|
* @param parsePricing Optional callback that extracts a connector-specific
|
|
11
12
|
* pricing shape from the raw parsed JSON. Returns
|
|
12
13
|
* `undefined` when the pricing block is missing or invalid.
|
|
@@ -16,29 +17,36 @@ import { mcpLog } from "../log.js";
|
|
|
16
17
|
*/
|
|
17
18
|
export function loadBaseConfig(configDir, parsePricing) {
|
|
18
19
|
const configPath = join(configDir, "config.json");
|
|
20
|
+
let parsed;
|
|
19
21
|
try {
|
|
20
|
-
|
|
21
|
-
if (typeof parsed === "object" && parsed !== null) {
|
|
22
|
-
const obj = parsed;
|
|
23
|
-
const result = {
|
|
24
|
-
token: typeof obj.token === "string" ? obj.token : undefined,
|
|
25
|
-
host: typeof obj.host === "string" ? obj.host : undefined,
|
|
26
|
-
project_id: typeof obj.project_id === "string" ? obj.project_id : undefined,
|
|
27
|
-
};
|
|
28
|
-
if (parsePricing) {
|
|
29
|
-
const pricing = parsePricing(obj);
|
|
30
|
-
if (pricing !== undefined)
|
|
31
|
-
result.pricing = pricing;
|
|
32
|
-
}
|
|
33
|
-
return result;
|
|
34
|
-
}
|
|
22
|
+
parsed = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
35
23
|
}
|
|
36
24
|
catch (err) {
|
|
37
25
|
const code = err?.code;
|
|
38
26
|
if (code !== "ENOENT") {
|
|
39
|
-
// Config file exists but
|
|
40
|
-
|
|
27
|
+
// Config file exists but is not valid JSON — distinguishes tampering from "never created".
|
|
28
|
+
// ENOENT stays silent: this file is optional and most users never create it.
|
|
29
|
+
mcpLog.warn("config_parse_failed", { path: configPath, ...describeReadFailure(err) }, false);
|
|
41
30
|
}
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
// Valid JSON, but not a config object. Arrays are rejected explicitly because
|
|
34
|
+
// `typeof [] === "object"` would otherwise let them reach the happy path and be handed
|
|
35
|
+
// to `parsePricing`. Previously every non-object fell through silently. (DB90DV-699)
|
|
36
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
37
|
+
mcpLog.warn("config_parse_failed", { path: configPath, reason: "invalid_shape" }, false);
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
const obj = parsed;
|
|
41
|
+
const result = {
|
|
42
|
+
token: typeof obj.token === "string" ? obj.token : undefined,
|
|
43
|
+
host: typeof obj.host === "string" ? obj.host : undefined,
|
|
44
|
+
project_id: typeof obj.project_id === "string" ? obj.project_id : undefined,
|
|
45
|
+
};
|
|
46
|
+
if (parsePricing) {
|
|
47
|
+
const pricing = parsePricing(obj);
|
|
48
|
+
if (pricing !== undefined)
|
|
49
|
+
result.pricing = pricing;
|
|
42
50
|
}
|
|
43
|
-
return
|
|
51
|
+
return result;
|
|
44
52
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type ReadFailureReason = "invalid_json" | "unreadable";
|
|
2
|
+
/**
|
|
3
|
+
* Classifies a caught error from `JSON.parse(readFileSync(...))` (or a parsed keychain
|
|
4
|
+
* payload) into a log-safe reason + error string.
|
|
5
|
+
*
|
|
6
|
+
* V8's `JSON.parse` throws a `SyntaxError` whose `.message` can embed a prefix (or, for a
|
|
7
|
+
* short enough input, the entirety) of the unparsed content — e.g.
|
|
8
|
+
* `JSON.parse("example_local_fixture_1234567890")` produces
|
|
9
|
+
* `Unexpected token 'e', "example_lo"... is not valid JSON`. Logging that message would
|
|
10
|
+
* leak exactly the secret content the parse-failure events exist to describe without
|
|
11
|
+
* exposing (see `credentials_parse_failed` / `credentials_keytar_parse_failed` /
|
|
12
|
+
* `config_parse_failed` / `state_parse_failed`). So for a `SyntaxError` this reports only
|
|
13
|
+
* the error name, never `.message`.
|
|
14
|
+
*
|
|
15
|
+
* Any other error (fs I/O — `EACCES`, `EISDIR`, etc.) is reported as `unreadable` using its
|
|
16
|
+
* errno `code`, which never contains file content and is more actionable than a bare name.
|
|
17
|
+
*/
|
|
18
|
+
export declare function describeReadFailure(err: unknown): {
|
|
19
|
+
reason: ReadFailureReason;
|
|
20
|
+
error: string;
|
|
21
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classifies a caught error from `JSON.parse(readFileSync(...))` (or a parsed keychain
|
|
3
|
+
* payload) into a log-safe reason + error string.
|
|
4
|
+
*
|
|
5
|
+
* V8's `JSON.parse` throws a `SyntaxError` whose `.message` can embed a prefix (or, for a
|
|
6
|
+
* short enough input, the entirety) of the unparsed content — e.g.
|
|
7
|
+
* `JSON.parse("example_local_fixture_1234567890")` produces
|
|
8
|
+
* `Unexpected token 'e', "example_lo"... is not valid JSON`. Logging that message would
|
|
9
|
+
* leak exactly the secret content the parse-failure events exist to describe without
|
|
10
|
+
* exposing (see `credentials_parse_failed` / `credentials_keytar_parse_failed` /
|
|
11
|
+
* `config_parse_failed` / `state_parse_failed`). So for a `SyntaxError` this reports only
|
|
12
|
+
* the error name, never `.message`.
|
|
13
|
+
*
|
|
14
|
+
* Any other error (fs I/O — `EACCES`, `EISDIR`, etc.) is reported as `unreadable` using its
|
|
15
|
+
* errno `code`, which never contains file content and is more actionable than a bare name.
|
|
16
|
+
*/
|
|
17
|
+
export function describeReadFailure(err) {
|
|
18
|
+
if (err instanceof SyntaxError) {
|
|
19
|
+
return { reason: "invalid_json", error: "SyntaxError" };
|
|
20
|
+
}
|
|
21
|
+
const code = err?.code;
|
|
22
|
+
if (code)
|
|
23
|
+
return { reason: "unreadable", error: code };
|
|
24
|
+
return { reason: "unreadable", error: err instanceof Error ? err.name : "unknown_error" };
|
|
25
|
+
}
|
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
export interface ProjectResolution {
|
|
2
2
|
projectId: string | null;
|
|
3
|
+
/**
|
|
4
|
+
* Repository that owns the resolved project's attribution, when the API
|
|
5
|
+
* resolved one from the git remote. Only populated on auto-detect (the API
|
|
6
|
+
* only knows the repository via the remote lookup). Null for flag/config
|
|
7
|
+
* attribution — an explicit project id carries no repository context.
|
|
8
|
+
*/
|
|
9
|
+
repositoryId: string | null;
|
|
3
10
|
source: "flag" | "config" | "auto-detect" | "auto-detect-not-found" | "none";
|
|
4
11
|
}
|
|
5
12
|
export interface LookupResult {
|
|
6
13
|
project_id: string;
|
|
14
|
+
repository_id?: string | null;
|
|
7
15
|
name: string;
|
|
8
16
|
}
|
|
9
17
|
export declare function resolveProjectId(flagValue: string | undefined, configValue: string | undefined, host: string, token: string, verbose: boolean, allowInsecureHttp?: boolean): Promise<ProjectResolution>;
|
|
@@ -10,29 +10,31 @@ export async function resolveProjectId(flagValue, configValue, host, token, verb
|
|
|
10
10
|
const flag = coerce(flagValue);
|
|
11
11
|
const config = coerce(configValue);
|
|
12
12
|
if (flag !== undefined)
|
|
13
|
-
return { projectId: flag, source: "flag" };
|
|
13
|
+
return { projectId: flag, repositoryId: null, source: "flag" };
|
|
14
14
|
if (config !== undefined)
|
|
15
|
-
return { projectId: config, source: "config" };
|
|
15
|
+
return { projectId: config, repositoryId: null, source: "config" };
|
|
16
16
|
const gitRemote = getGitRemote(verbose);
|
|
17
17
|
if (gitRemote === null)
|
|
18
|
-
return { projectId: null, source: "none" };
|
|
18
|
+
return { projectId: null, repositoryId: null, source: "none" };
|
|
19
19
|
const result = await lookupProjectByRemote(gitRemote, host, token, verbose, allowInsecureHttp);
|
|
20
20
|
if (result === "not-found")
|
|
21
|
-
return { projectId: null, source: "auto-detect-not-found" };
|
|
22
|
-
if (result !== null)
|
|
23
|
-
return { projectId: result.project_id, source: "auto-detect" };
|
|
24
|
-
|
|
21
|
+
return { projectId: null, repositoryId: null, source: "auto-detect-not-found" };
|
|
22
|
+
if (result !== null) {
|
|
23
|
+
return { projectId: result.project_id, repositoryId: result.repository_id ?? null, source: "auto-detect" };
|
|
24
|
+
}
|
|
25
|
+
return { projectId: null, repositoryId: null, source: "none" };
|
|
25
26
|
}
|
|
26
27
|
export async function resolveProjectIdForRepoPath(repoPath, host, token, verbose, allowInsecureHttp = false) {
|
|
27
28
|
const gitRemote = getGitRemoteForPath(repoPath, verbose);
|
|
28
29
|
if (gitRemote === null)
|
|
29
|
-
return { projectId: null, source: "none" };
|
|
30
|
+
return { projectId: null, repositoryId: null, source: "none" };
|
|
30
31
|
const result = await lookupProjectByRemote(gitRemote, host, token, verbose, allowInsecureHttp);
|
|
31
32
|
if (result === "not-found")
|
|
32
|
-
return { projectId: null, source: "auto-detect-not-found" };
|
|
33
|
-
if (result !== null)
|
|
34
|
-
return { projectId: result.project_id, source: "auto-detect" };
|
|
35
|
-
|
|
33
|
+
return { projectId: null, repositoryId: null, source: "auto-detect-not-found" };
|
|
34
|
+
if (result !== null) {
|
|
35
|
+
return { projectId: result.project_id, repositoryId: result.repository_id ?? null, source: "auto-detect" };
|
|
36
|
+
}
|
|
37
|
+
return { projectId: null, repositoryId: null, source: "none" };
|
|
36
38
|
}
|
|
37
39
|
export function getGitRemote(verbose) {
|
|
38
40
|
try {
|
|
@@ -145,7 +147,10 @@ function isLookupResponse(body) {
|
|
|
145
147
|
if (typeof d !== "object" || d === null)
|
|
146
148
|
return false;
|
|
147
149
|
const data = d;
|
|
148
|
-
|
|
150
|
+
if (typeof data.project_id !== "string" || typeof data.name !== "string")
|
|
151
|
+
return false;
|
|
152
|
+
// repository_id is optional (older servers omit it) but must be a string when present.
|
|
153
|
+
return data.repository_id == null || typeof data.repository_id === "string";
|
|
149
154
|
}
|
|
150
155
|
/**
|
|
151
156
|
* Cursor `recentCommit.repoName` is typically `owner/repo` (GitHub-style slug), not a full
|
package/dist/readers/claude.d.ts
CHANGED
|
@@ -72,6 +72,7 @@ export interface ClaudePayload extends IngestPayload {
|
|
|
72
72
|
cost_usd: number | null;
|
|
73
73
|
occurred_at: string;
|
|
74
74
|
project_id?: string;
|
|
75
|
+
repository_id?: string;
|
|
75
76
|
metadata: {
|
|
76
77
|
session_id: string;
|
|
77
78
|
claude_session_id: string;
|
|
@@ -121,6 +122,7 @@ export type ClaudeMappedPayload = ClaudePayload | ClaudeDerivativePayload;
|
|
|
121
122
|
/** Options for mapTranscriptTurn. */
|
|
122
123
|
export interface ToClaudePayloadOptions {
|
|
123
124
|
projectId?: string | null;
|
|
125
|
+
repositoryId?: string | null;
|
|
124
126
|
pricing?: PricingTable;
|
|
125
127
|
}
|
|
126
128
|
/** Finds all *.jsonl transcript files across both Claude project directory roots. */
|
package/dist/readers/claude.js
CHANGED
|
@@ -395,7 +395,7 @@ export async function parseTranscriptFile(filePath, verbose = false) {
|
|
|
395
395
|
}
|
|
396
396
|
/** Converts a Claude transcript turn to parent chat and derivative tool-use payloads. */
|
|
397
397
|
export function mapTranscriptTurn(turn, options) {
|
|
398
|
-
const { projectId, pricing } = options ?? {};
|
|
398
|
+
const { projectId, repositoryId, pricing } = options ?? {};
|
|
399
399
|
const baseInputTokens = Math.max(0, turn.tokensIn - turn.cacheWriteTokens - turn.cacheReadTokens);
|
|
400
400
|
const cost = pricing
|
|
401
401
|
? calculateCost(turn.model, baseInputTokens, turn.tokensOut, turn.cacheWriteTokens, turn.cacheReadTokens, pricing)
|
|
@@ -438,6 +438,8 @@ export function mapTranscriptTurn(turn, options) {
|
|
|
438
438
|
}
|
|
439
439
|
if (projectId)
|
|
440
440
|
payload.project_id = projectId;
|
|
441
|
+
if (repositoryId)
|
|
442
|
+
payload.repository_id = repositoryId;
|
|
441
443
|
const derivatives = turn.toolUses.map((toolUse) => {
|
|
442
444
|
const derivative = {
|
|
443
445
|
tool_name: "claude_code",
|
package/dist/readers/cursor.d.ts
CHANGED
|
@@ -122,6 +122,7 @@ export interface CursorPayload extends IngestPayload {
|
|
|
122
122
|
cost_usd: number;
|
|
123
123
|
occurred_at: string;
|
|
124
124
|
project_id?: string;
|
|
125
|
+
repository_id?: string;
|
|
125
126
|
metadata: CursorPayloadMetadata;
|
|
126
127
|
}
|
|
127
128
|
export declare function toEpochMs(timestamp: number | string | null | undefined): number | null;
|
package/dist/server.js
CHANGED
|
@@ -80,7 +80,7 @@ let cachedProjectResolution = null;
|
|
|
80
80
|
async function getProjectResolutionForSync(creds) {
|
|
81
81
|
const token = pickProjectLookupToken(creds);
|
|
82
82
|
if (!token)
|
|
83
|
-
return { projectId: null, source: "none" };
|
|
83
|
+
return { projectId: null, repositoryId: null, source: "none" };
|
|
84
84
|
const gitRemote = getGitRemote(false) ?? "no-remote";
|
|
85
85
|
const cacheKey = `${creds.host}|${token}|${gitRemote}`;
|
|
86
86
|
if (cachedProjectResolution?.key === cacheKey) {
|
|
@@ -112,6 +112,7 @@ async function executeSync(parsed) {
|
|
|
112
112
|
verbose: false,
|
|
113
113
|
projectId: projectResolution.projectId,
|
|
114
114
|
projectIdSource: projectResolution.source,
|
|
115
|
+
repositoryId: projectResolution.repositoryId,
|
|
115
116
|
projectLookupToken: pickProjectLookupToken(creds),
|
|
116
117
|
pricing: defaultPricing(),
|
|
117
118
|
cursorPricing: cursorPricingForSync(),
|
|
@@ -246,6 +247,7 @@ export async function startServer() {
|
|
|
246
247
|
verbose: false,
|
|
247
248
|
projectId: projectResolution.projectId,
|
|
248
249
|
projectIdSource: projectResolution.source,
|
|
250
|
+
repositoryId: projectResolution.repositoryId,
|
|
249
251
|
projectLookupToken: pickProjectLookupToken(creds),
|
|
250
252
|
pricing: defaultPricing(),
|
|
251
253
|
scopeDir: process.cwd(),
|
package/dist/state.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { createHash, randomBytes } from "node:crypto";
|
|
5
5
|
import { mcpLog } from "./log.js";
|
|
6
|
+
import { describeReadFailure } from "./lib/parse-error.js";
|
|
6
7
|
export function getAppDir() {
|
|
7
8
|
const override = process.env["AIXLE_INSIGHTS_HOME"]?.trim();
|
|
8
9
|
if (override && override.length > 0)
|
|
@@ -90,48 +91,50 @@ export function migrateLegacyState(dir, host, token) {
|
|
|
90
91
|
}
|
|
91
92
|
export function readState(dir, host, token) {
|
|
92
93
|
const filePath = stateFilePath(dir ?? getAppDir(), host, token);
|
|
94
|
+
let parsed;
|
|
93
95
|
try {
|
|
94
|
-
|
|
95
|
-
if (typeof parsed === "object" && parsed !== null) {
|
|
96
|
-
const p = parsed;
|
|
97
|
-
if (typeof p.version === "number" &&
|
|
98
|
-
typeof p.sessions === "object" &&
|
|
99
|
-
p.sessions !== null) {
|
|
100
|
-
const lastRecentCommitHashes = Array.isArray(p.lastRecentCommitHashes)
|
|
101
|
-
? p.lastRecentCommitHashes.filter((h) => typeof h === "string")
|
|
102
|
-
: undefined;
|
|
103
|
-
const out = {
|
|
104
|
-
version: p.version,
|
|
105
|
-
sessions: p.sessions,
|
|
106
|
-
};
|
|
107
|
-
if (lastRecentCommitHashes !== undefined) {
|
|
108
|
-
out.lastRecentCommitHashes = lastRecentCommitHashes;
|
|
109
|
-
}
|
|
110
|
-
if ("mcp_operator" in p) {
|
|
111
|
-
const mcp = parseMcpOperator(p.mcp_operator);
|
|
112
|
-
if (mcp)
|
|
113
|
-
out.mcp_operator = mcp;
|
|
114
|
-
}
|
|
115
|
-
if ("rate_limited_until" in p) {
|
|
116
|
-
if (typeof p.rate_limited_until === "string") {
|
|
117
|
-
out.rate_limited_until = p.rate_limited_until;
|
|
118
|
-
}
|
|
119
|
-
else if (p.rate_limited_until === null) {
|
|
120
|
-
out.rate_limited_until = null;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
return out;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
96
|
+
parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
126
97
|
}
|
|
127
98
|
catch (err) {
|
|
128
99
|
const code = err?.code;
|
|
129
100
|
if (code !== "ENOENT") {
|
|
130
|
-
// State file exists but
|
|
131
|
-
|
|
101
|
+
// State file exists but is not valid JSON — distinguishes tampering from "never created".
|
|
102
|
+
// ENOENT stays silent: that is the normal first-run case.
|
|
103
|
+
mcpLog.warn("state_parse_failed", { path: filePath, ...describeReadFailure(err) }, false);
|
|
104
|
+
}
|
|
105
|
+
return { version: 1, sessions: {} };
|
|
106
|
+
}
|
|
107
|
+
const p = typeof parsed === "object" && parsed !== null ? parsed : null;
|
|
108
|
+
if (p === null || typeof p.version !== "number" || typeof p.sessions !== "object" || p.sessions === null) {
|
|
109
|
+
// Valid JSON, wrong shape. This fallback discards every dedup checkpoint and causes a full
|
|
110
|
+
// re-send, so it is the most consequential of the four to have been silent. (DB90DV-699)
|
|
111
|
+
mcpLog.warn("state_parse_failed", { path: filePath, reason: "invalid_shape" }, false);
|
|
112
|
+
return { version: 1, sessions: {} };
|
|
113
|
+
}
|
|
114
|
+
const lastRecentCommitHashes = Array.isArray(p.lastRecentCommitHashes)
|
|
115
|
+
? p.lastRecentCommitHashes.filter((h) => typeof h === "string")
|
|
116
|
+
: undefined;
|
|
117
|
+
const out = {
|
|
118
|
+
version: p.version,
|
|
119
|
+
sessions: p.sessions,
|
|
120
|
+
};
|
|
121
|
+
if (lastRecentCommitHashes !== undefined) {
|
|
122
|
+
out.lastRecentCommitHashes = lastRecentCommitHashes;
|
|
123
|
+
}
|
|
124
|
+
if ("mcp_operator" in p) {
|
|
125
|
+
const mcp = parseMcpOperator(p.mcp_operator);
|
|
126
|
+
if (mcp)
|
|
127
|
+
out.mcp_operator = mcp;
|
|
128
|
+
}
|
|
129
|
+
if ("rate_limited_until" in p) {
|
|
130
|
+
if (typeof p.rate_limited_until === "string") {
|
|
131
|
+
out.rate_limited_until = p.rate_limited_until;
|
|
132
|
+
}
|
|
133
|
+
else if (p.rate_limited_until === null) {
|
|
134
|
+
out.rate_limited_until = null;
|
|
132
135
|
}
|
|
133
136
|
}
|
|
134
|
-
return
|
|
137
|
+
return out;
|
|
135
138
|
}
|
|
136
139
|
/** Atomic write: write to a temp file then rename over the target. */
|
|
137
140
|
export function writeState(state, dir, host, token) {
|
package/dist/sync.d.ts
CHANGED
|
@@ -26,6 +26,12 @@ export interface SyncOptions {
|
|
|
26
26
|
projectIdSource?: ProjectResolution["source"];
|
|
27
27
|
/** Mirrors StoredCredentials.insecureHttpAllowed — set when `init --insecure` was used for this host. */
|
|
28
28
|
allowInsecureHttp?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Repository resolved from the same remote lookup as the scoped `projectId`.
|
|
31
|
+
* Only meaningful alongside a pre-resolved scoped `projectId` — carries the
|
|
32
|
+
* repository attribution the lookup returned so scoped turns don't lose it.
|
|
33
|
+
*/
|
|
34
|
+
repositoryId?: string | null;
|
|
29
35
|
pricing: PricingTable;
|
|
30
36
|
appDir?: string;
|
|
31
37
|
transcriptBaseDirs?: string[];
|
|
@@ -44,6 +50,8 @@ export interface MultiSyncOptions {
|
|
|
44
50
|
verbose: boolean;
|
|
45
51
|
projectId: string | null;
|
|
46
52
|
projectIdSource?: ProjectResolution["source"];
|
|
53
|
+
/** See SyncOptions.repositoryId. */
|
|
54
|
+
repositoryId?: string | null;
|
|
47
55
|
/** Token for GET /projects/lookup (defaults to cursor ingest token in cursor slice). */
|
|
48
56
|
projectLookupToken?: string | null;
|
|
49
57
|
pricing: PricingTable;
|
package/dist/sync.js
CHANGED
|
@@ -103,21 +103,24 @@ function explicitProjectId(projectId, projectIdSource) {
|
|
|
103
103
|
? projectId
|
|
104
104
|
: undefined;
|
|
105
105
|
}
|
|
106
|
-
async function
|
|
106
|
+
async function resolveAttributionForRepoPathCached(repoPath, host, token, verbose, cache, allowInsecureHttp = false) {
|
|
107
|
+
const empty = { projectId: null, repositoryId: null };
|
|
107
108
|
const normalized = normalizeRepoPathCandidate(repoPath);
|
|
108
109
|
if (normalized === null)
|
|
109
|
-
return
|
|
110
|
+
return empty;
|
|
110
111
|
if (cache.has(normalized))
|
|
111
|
-
return cache.get(normalized) ??
|
|
112
|
+
return cache.get(normalized) ?? empty;
|
|
112
113
|
const canonicalRemote = getGitRemoteForPath(normalized, verbose);
|
|
113
114
|
if (!canonicalRemote) {
|
|
114
|
-
cache.set(normalized,
|
|
115
|
-
return
|
|
115
|
+
cache.set(normalized, empty);
|
|
116
|
+
return empty;
|
|
116
117
|
}
|
|
117
118
|
const result = await lookupProjectByRemote(canonicalRemote, host, token, verbose, allowInsecureHttp);
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
const resolved = result && typeof result === "object" && "project_id" in result
|
|
120
|
+
? { projectId: result.project_id, repositoryId: result.repository_id ?? null }
|
|
121
|
+
: empty;
|
|
122
|
+
cache.set(normalized, resolved);
|
|
123
|
+
return resolved;
|
|
121
124
|
}
|
|
122
125
|
/**
|
|
123
126
|
* The repo path for a Cursor payload, normalized and safe to hand to project
|
|
@@ -143,6 +146,7 @@ export function cursorRepoPathFromPayload(payload) {
|
|
|
143
146
|
}
|
|
144
147
|
async function runClaudeSlice(options) {
|
|
145
148
|
const { token, host, dryRun, verbose, projectId, pricing, allowInsecureHttp = false } = options;
|
|
149
|
+
const preResolvedRepositoryId = options.repositoryId ?? undefined;
|
|
146
150
|
const appDir = options.appDir ?? getAppDir();
|
|
147
151
|
const backoffKey = credentialStateKey(host, token);
|
|
148
152
|
const errors = [];
|
|
@@ -233,14 +237,22 @@ async function runClaudeSlice(options) {
|
|
|
233
237
|
// (e.g. MCP server launched from a non-git cwd, or stuck null in module cache),
|
|
234
238
|
// fall back to per-turn cwd lookup. The lookup cache dedupes by path so this is
|
|
235
239
|
// effectively one network call per unique cwd per sync.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
240
|
+
// The scoped pre-resolve comes from the same remote lookup as projectId, so it
|
|
241
|
+
// carries repository_id too. An explicit flag/config projectId carries no
|
|
242
|
+
// repository context, so repository stays undefined there.
|
|
243
|
+
const preResolved = scopeDir ? projectId : explicitProject;
|
|
244
|
+
let resolvedProjectId = preResolved ?? undefined;
|
|
245
|
+
let resolvedRepositoryId = scopeDir && preResolved != null ? preResolvedRepositoryId : undefined;
|
|
246
|
+
if (resolvedProjectId === undefined) {
|
|
247
|
+
const attribution = await resolveAttributionForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache, allowInsecureHttp);
|
|
248
|
+
resolvedProjectId = attribution.projectId ?? undefined;
|
|
249
|
+
resolvedRepositoryId = attribution.repositoryId ?? undefined;
|
|
250
|
+
}
|
|
251
|
+
const payloads = mapClaudeTranscriptTurn(turn, {
|
|
252
|
+
projectId: resolvedProjectId,
|
|
253
|
+
repositoryId: resolvedRepositoryId,
|
|
254
|
+
pricing,
|
|
255
|
+
});
|
|
244
256
|
if (!payloads?.length)
|
|
245
257
|
continue;
|
|
246
258
|
const parentPayload = payloads[0];
|
|
@@ -315,6 +327,7 @@ async function runClaudeSlice(options) {
|
|
|
315
327
|
}
|
|
316
328
|
async function runCursorSlice(params) {
|
|
317
329
|
const { token, host, dryRun, verbose, projectId, projectIdSource, projectLookupToken, allowInsecureHttp = false, appDir, cursorBaseDir, cursorTranscriptProjectDirs, scopeDir, fullScan = false, cursorPricing = resolveCursorPricing(undefined, appDir), } = params;
|
|
330
|
+
const preResolvedRepositoryId = params.repositoryId ?? undefined;
|
|
318
331
|
const backoffKey = credentialStateKey(host, token);
|
|
319
332
|
// Read state first so we can restore a persisted rate-limit backoff from a prior process.
|
|
320
333
|
const stateBefore = readState(appDir, host, token);
|
|
@@ -369,12 +382,26 @@ async function runCursorSlice(params) {
|
|
|
369
382
|
if (ws && isRepoPathWithinRoot(ws, scopeDir)) {
|
|
370
383
|
// Same fallback as Claude: when the pre-resolved projectId is null, do a
|
|
371
384
|
// per-payload lookup from the payload's workspace. Cache dedupes by path.
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
if (
|
|
375
|
-
payload.project_id =
|
|
376
|
-
|
|
377
|
-
|
|
385
|
+
// The scoped pre-resolve comes from the same remote lookup as projectId,
|
|
386
|
+
// so it carries repository_id too; a fresh lookup sets it otherwise.
|
|
387
|
+
if (projectId) {
|
|
388
|
+
payload.project_id = projectId;
|
|
389
|
+
if (preResolvedRepositoryId)
|
|
390
|
+
payload.repository_id = preResolvedRepositoryId;
|
|
391
|
+
else
|
|
392
|
+
delete payload.repository_id;
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
const attribution = await resolveAttributionForRepoPathCached(ws, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp);
|
|
396
|
+
if (attribution.projectId)
|
|
397
|
+
payload.project_id = attribution.projectId;
|
|
398
|
+
else
|
|
399
|
+
delete payload.project_id;
|
|
400
|
+
if (attribution.repositoryId)
|
|
401
|
+
payload.repository_id = attribution.repositoryId;
|
|
402
|
+
else
|
|
403
|
+
delete payload.repository_id;
|
|
404
|
+
}
|
|
378
405
|
inScope.push(payload);
|
|
379
406
|
}
|
|
380
407
|
else if (verbose) {
|
|
@@ -390,11 +417,15 @@ async function runCursorSlice(params) {
|
|
|
390
417
|
continue;
|
|
391
418
|
for (const payload of group.payloads) {
|
|
392
419
|
const repoPath = cursorRepoPathFromPayload(payload);
|
|
393
|
-
const
|
|
394
|
-
if (
|
|
395
|
-
payload.project_id =
|
|
420
|
+
const attribution = await resolveAttributionForRepoPathCached(repoPath, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp);
|
|
421
|
+
if (attribution.projectId)
|
|
422
|
+
payload.project_id = attribution.projectId;
|
|
396
423
|
else
|
|
397
424
|
delete payload.project_id;
|
|
425
|
+
if (attribution.repositoryId)
|
|
426
|
+
payload.repository_id = attribution.repositoryId;
|
|
427
|
+
else
|
|
428
|
+
delete payload.repository_id;
|
|
398
429
|
}
|
|
399
430
|
}
|
|
400
431
|
}
|
|
@@ -530,7 +561,7 @@ async function runCursorSlice(params) {
|
|
|
530
561
|
on429,
|
|
531
562
|
resolveProjectId: explicitProject
|
|
532
563
|
? undefined
|
|
533
|
-
: (workspace) =>
|
|
564
|
+
: (workspace) => resolveAttributionForRepoPathCached(workspace, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp),
|
|
534
565
|
verbose,
|
|
535
566
|
});
|
|
536
567
|
totalSent += hooksResult.sent;
|
|
@@ -578,6 +609,7 @@ export async function syncTelemetryTools(options) {
|
|
|
578
609
|
}
|
|
579
610
|
try {
|
|
580
611
|
const { credentials, dryRun, verbose, projectId, projectIdSource, projectLookupToken, pricing, scopeDir } = options;
|
|
612
|
+
const repositoryId = options.repositoryId ?? null;
|
|
581
613
|
const host = credentials.host;
|
|
582
614
|
const appDirResolved = options.appDir ?? getAppDir();
|
|
583
615
|
const cursorPricing = options.cursorPricing ?? resolveCursorPricing(undefined, appDirResolved);
|
|
@@ -617,6 +649,7 @@ export async function syncTelemetryTools(options) {
|
|
|
617
649
|
verbose,
|
|
618
650
|
projectId,
|
|
619
651
|
allowInsecureHttp: credentials.insecureHttpAllowed === true,
|
|
652
|
+
repositoryId,
|
|
620
653
|
pricing,
|
|
621
654
|
appDir,
|
|
622
655
|
transcriptBaseDirs: options.transcriptBaseDirs,
|
|
@@ -632,6 +665,7 @@ export async function syncTelemetryTools(options) {
|
|
|
632
665
|
verbose,
|
|
633
666
|
projectId,
|
|
634
667
|
projectIdSource,
|
|
668
|
+
repositoryId,
|
|
635
669
|
projectLookupToken,
|
|
636
670
|
allowInsecureHttp: credentials.insecureHttpAllowed === true,
|
|
637
671
|
appDir,
|
|
@@ -699,9 +733,12 @@ export async function syncOnce(options) {
|
|
|
699
733
|
dryRun: options.dryRun,
|
|
700
734
|
verbose: options.verbose,
|
|
701
735
|
projectId: options.projectId,
|
|
736
|
+
projectIdSource: options.projectIdSource,
|
|
737
|
+
repositoryId: options.repositoryId,
|
|
702
738
|
pricing: options.pricing,
|
|
703
739
|
appDir: options.appDir,
|
|
704
740
|
transcriptBaseDirs: options.transcriptBaseDirs,
|
|
741
|
+
scopeDir: options.scopeDir,
|
|
705
742
|
tools: ["claude_code"],
|
|
706
743
|
});
|
|
707
744
|
}
|
package/package.json
CHANGED