@aixle/insights 0.2.4-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/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/state.js +38 -35
- 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/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
|
+
}
|
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/package.json
CHANGED