litmus-cli 1.4.0 → 1.4.2

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.
Files changed (46) hide show
  1. package/dist/commands/doctor.d.ts +136 -0
  2. package/dist/commands/doctor.d.ts.map +1 -0
  3. package/dist/commands/doctor.js +1055 -0
  4. package/dist/commands/doctor.js.map +1 -0
  5. package/dist/commands/init.d.ts +1 -0
  6. package/dist/commands/init.d.ts.map +1 -1
  7. package/dist/commands/init.js +15 -2
  8. package/dist/commands/init.js.map +1 -1
  9. package/dist/commands/status.d.ts.map +1 -1
  10. package/dist/commands/status.js +24 -10
  11. package/dist/commands/status.js.map +1 -1
  12. package/dist/index.js +25 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/lib/ai-tracking.d.ts +35 -0
  15. package/dist/lib/ai-tracking.d.ts.map +1 -1
  16. package/dist/lib/ai-tracking.js +40 -10
  17. package/dist/lib/ai-tracking.js.map +1 -1
  18. package/dist/lib/api.d.ts +14 -0
  19. package/dist/lib/api.d.ts.map +1 -1
  20. package/dist/lib/api.js +40 -0
  21. package/dist/lib/api.js.map +1 -1
  22. package/dist/lib/backfill.d.ts +106 -0
  23. package/dist/lib/backfill.d.ts.map +1 -0
  24. package/dist/lib/backfill.js +356 -0
  25. package/dist/lib/backfill.js.map +1 -0
  26. package/dist/lib/chain.d.ts +42 -0
  27. package/dist/lib/chain.d.ts.map +1 -0
  28. package/dist/lib/chain.js +98 -0
  29. package/dist/lib/chain.js.map +1 -0
  30. package/dist/lib/detect-project.d.ts +35 -0
  31. package/dist/lib/detect-project.d.ts.map +1 -1
  32. package/dist/lib/detect-project.js +111 -3
  33. package/dist/lib/detect-project.js.map +1 -1
  34. package/dist/lib/errors.d.ts +19 -0
  35. package/dist/lib/errors.d.ts.map +1 -1
  36. package/dist/lib/errors.js +9 -1
  37. package/dist/lib/errors.js.map +1 -1
  38. package/dist/lib/tracker.d.ts +26 -0
  39. package/dist/lib/tracker.d.ts.map +1 -1
  40. package/dist/lib/tracker.js +137 -20
  41. package/dist/lib/tracker.js.map +1 -1
  42. package/dist/lib/watcher.js +270 -5
  43. package/dist/lib/watcher.js.map +1 -1
  44. package/dist/lib/zip.js +2 -2
  45. package/dist/lib/zip.js.map +1 -1
  46. package/package.json +1 -1
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Tamper-evidence chain resume for the activity log.
3
+ *
4
+ * Watcher events carry `_seq` / `_prevHash` / `_hash` fields forming a hash
5
+ * chain. The watcher used to initialize the chain at genesis (`seq 0`,
6
+ * all-zero prev hash) on every start — fine when a watcher started exactly
7
+ * once per assessment, but a watcher can now be legitimately restarted
8
+ * mid-assessment (crash + `litmus doctor` revival, machine reboot on a
9
+ * multi-day assessment). Each genesis restart would leave activity.jsonl
10
+ * holding several disjoint chains, losing the single-sequence property the
11
+ * chain fields exist to provide.
12
+ *
13
+ * Instead, a starting watcher resumes from the LAST chained event already in
14
+ * the log: next `_seq` continues the count, `_prevHash` links to the last
15
+ * `_hash`. Non-chained events (the hook logger's `ai_prompt` / `ai_response`
16
+ * lines carry no chain fields) are skipped, as are malformed lines.
17
+ */
18
+ export interface ChainResumePoint {
19
+ nextSeq: number;
20
+ prevHash: string;
21
+ }
22
+ export declare const GENESIS_HASH: string;
23
+ /**
24
+ * Find the resume point in raw activity-log text (newest chained line wins).
25
+ * The text may start mid-line (tail reads truncate); unparseable lines are
26
+ * skipped, so a torn first line never matters.
27
+ */
28
+ export declare function resumeChainFromLogText(text: string): ChainResumePoint;
29
+ /**
30
+ * Return where the chain should resume by scanning `activityLogPath` backward,
31
+ * window by window, until a chained event is found. Missing or unreadable
32
+ * log → genesis (a fresh assessment has no chain yet). Never writes; a
33
+ * failure here must not stop the watcher from starting.
34
+ *
35
+ * A line can straddle a window boundary, so each window carries the bytes
36
+ * before its predecessor's first newline (`carry`) and re-parses that
37
+ * boundary line whole. The carry is raw bytes, not decoded text: slicing a
38
+ * multi-byte UTF-8 character mid-sequence and decoding the halves separately
39
+ * would corrupt the very line being reconstructed.
40
+ */
41
+ export declare function resumeChainFromLog(activityLogPath: string): ChainResumePoint;
42
+ //# sourceMappingURL=chain.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chain.d.ts","sourceRoot":"","sources":["../../src/lib/chain.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,MAAM,CAAA;IACf,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,eAAO,MAAM,YAAY,QAAiB,CAAA;AA+C1C;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,gBAAgB,CAErE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,eAAe,EAAE,MAAM,GAAG,gBAAgB,CA2B5E"}
@@ -0,0 +1,98 @@
1
+ import fs from "fs";
2
+ export const GENESIS_HASH = "0".repeat(64);
3
+ const GENESIS = { nextSeq: 0, prevHash: GENESIS_HASH };
4
+ // The log is scanned BACKWARD in windows of this size until a chained event
5
+ // is found (or the file start is reached). A single bounded tail read is not
6
+ // enough: unchained events — the hook logger's ai_prompt/ai_response lines,
7
+ // and especially a doctor prompt backfill, which can append hundreds of KB of
8
+ // them in one run — can pile up after the last chained watcher event, and a
9
+ // scan that gives up after one window would silently restart the chain at
10
+ // genesis. The scan reads exactly as far back as the newest chained event,
11
+ // which in practice is within the first window (heartbeats land every 5
12
+ // minutes whenever a watcher is running).
13
+ const CHAIN_SCAN_WINDOW_BYTES = 256 * 1024;
14
+ const HASH_PATTERN = /^[0-9a-f]{64}$/;
15
+ function chainedPointFromLine(line) {
16
+ let event;
17
+ try {
18
+ event = JSON.parse(line);
19
+ }
20
+ catch {
21
+ return null; // torn or malformed line
22
+ }
23
+ if (!event || typeof event !== "object")
24
+ return null;
25
+ const { _seq, _hash } = event;
26
+ if (typeof _seq === "number" && Number.isInteger(_seq) && _seq >= 0 &&
27
+ typeof _hash === "string" && HASH_PATTERN.test(_hash)) {
28
+ return { nextSeq: _seq + 1, prevHash: _hash };
29
+ }
30
+ return null;
31
+ }
32
+ /** Newest chained line in the text, or null. Unparseable lines are skipped. */
33
+ function scanTextForChained(text) {
34
+ const lines = text.split("\n");
35
+ for (let i = lines.length - 1; i >= 0; i--) {
36
+ const line = lines[i].trim();
37
+ if (!line)
38
+ continue;
39
+ const point = chainedPointFromLine(line);
40
+ if (point)
41
+ return point;
42
+ }
43
+ return null;
44
+ }
45
+ /**
46
+ * Find the resume point in raw activity-log text (newest chained line wins).
47
+ * The text may start mid-line (tail reads truncate); unparseable lines are
48
+ * skipped, so a torn first line never matters.
49
+ */
50
+ export function resumeChainFromLogText(text) {
51
+ return scanTextForChained(text) ?? GENESIS;
52
+ }
53
+ /**
54
+ * Return where the chain should resume by scanning `activityLogPath` backward,
55
+ * window by window, until a chained event is found. Missing or unreadable
56
+ * log → genesis (a fresh assessment has no chain yet). Never writes; a
57
+ * failure here must not stop the watcher from starting.
58
+ *
59
+ * A line can straddle a window boundary, so each window carries the bytes
60
+ * before its predecessor's first newline (`carry`) and re-parses that
61
+ * boundary line whole. The carry is raw bytes, not decoded text: slicing a
62
+ * multi-byte UTF-8 character mid-sequence and decoding the halves separately
63
+ * would corrupt the very line being reconstructed.
64
+ */
65
+ export function resumeChainFromLog(activityLogPath) {
66
+ try {
67
+ const stat = fs.statSync(activityLogPath);
68
+ if (stat.size === 0)
69
+ return GENESIS;
70
+ const fd = fs.openSync(activityLogPath, "r");
71
+ try {
72
+ let end = stat.size; // exclusive end of the region still unscanned
73
+ let carry = Buffer.alloc(0); // partial first line of the window above
74
+ while (end > 0) {
75
+ const start = Math.max(0, end - CHAIN_SCAN_WINDOW_BYTES);
76
+ const buf = Buffer.alloc(end - start);
77
+ fs.readSync(fd, buf, 0, buf.length, start);
78
+ const window = carry.length > 0 ? Buffer.concat([buf, carry]) : buf;
79
+ const point = scanTextForChained(window.toString("utf8"));
80
+ if (point)
81
+ return point;
82
+ if (start === 0)
83
+ break;
84
+ const firstNewline = window.indexOf(0x0a);
85
+ carry = firstNewline === -1 ? window : window.subarray(0, firstNewline);
86
+ end = start;
87
+ }
88
+ return GENESIS;
89
+ }
90
+ finally {
91
+ fs.closeSync(fd);
92
+ }
93
+ }
94
+ catch {
95
+ return GENESIS;
96
+ }
97
+ }
98
+ //# sourceMappingURL=chain.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chain.js","sourceRoot":"","sources":["../../src/lib/chain.ts"],"names":[],"mappings":"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"}
@@ -2,7 +2,42 @@ export type ProjectType = "node" | "python" | "go" | "java" | "dotnet" | "rust"
2
2
  export interface ProjectInfo {
3
3
  type: ProjectType;
4
4
  installCommand: string | null;
5
+ /**
6
+ * Set for `python` projects when the venv had to be built on an interpreter
7
+ * that is NOT the one the submission gets graded on. `init` surfaces it so
8
+ * the candidate learns about the skew here, rather than from a syntax error
9
+ * the README told them could not happen.
10
+ */
11
+ toolchainWarning?: string;
5
12
  }
13
+ /**
14
+ * The Python the candidate's submission is actually GRADED on.
15
+ *
16
+ * Not a floor — an exact version, because we own both ends. The E2B grading
17
+ * image (`backend/assessment/template.py`) installs 3.12 from deadsnakes and
18
+ * points the candidate-facing `python`/`python3`/`pip` at it, and the workspace
19
+ * image is `litmus-base-python-3.12`. Anything a candidate writes has to run
20
+ * there, so that is the interpreter their local venv should be built on.
21
+ *
22
+ * Keep in sync with `GRADING_PYTHON_VERSION` in `backend/agent/system_prompt.py`
23
+ * and the deadsnakes pin in `backend/assessment/template.py` —
24
+ * `backend/tests/test_candidate_python_version_alignment.py` fails if they drift.
25
+ */
26
+ export declare const GRADING_PYTHON_VERSION = "3.12";
27
+ /**
28
+ * Pick the interpreter to build the assessment venv with.
29
+ *
30
+ * Never fatal: a candidate with no `GRADING_PYTHON_VERSION` still gets a venv
31
+ * and a warning. Blocking `litmus init` on a missing interpreter would be a far
32
+ * worse failure than the mismatch itself. If nothing probes clean we still
33
+ * return the platform's conventional name so the install is *attempted* — and
34
+ * `init` reports that attempt's outcome separately.
35
+ */
36
+ export declare function resolvePythonInterpreter(): {
37
+ command: string;
38
+ matchesGrader: boolean;
39
+ detectedVersion: string | null;
40
+ };
6
41
  /**
7
42
  * Detect project type and suggest an install command.
8
43
  */
@@ -1 +1 @@
1
- {"version":3,"file":"detect-project.d.ts","sourceRoot":"","sources":["../../src/lib/detect-project.ts"],"names":[],"mappings":"AAGA,MAAM,MAAM,WAAW,GACnB,MAAM,GACN,QAAQ,GACR,IAAI,GACJ,MAAM,GACN,QAAQ,GACR,MAAM,GACN,MAAM,GACN,KAAK,GACL,SAAS,CAAA;AAEb,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,WAAW,CAAA;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;CAC9B;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,WAAW,CAwDtD"}
1
+ {"version":3,"file":"detect-project.d.ts","sourceRoot":"","sources":["../../src/lib/detect-project.ts"],"names":[],"mappings":"AAIA,MAAM,MAAM,WAAW,GACnB,MAAM,GACN,QAAQ,GACR,IAAI,GACJ,MAAM,GACN,QAAQ,GACR,MAAM,GACN,MAAM,GACN,KAAK,GACL,SAAS,CAAA;AAEb,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,WAAW,CAAA;IACjB,cAAc,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAA;CAC1B;AAED;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,sBAAsB,SAAS,CAAA;AA0D5C;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,IAAI;IAC1C,OAAO,EAAE,MAAM,CAAA;IACf,aAAa,EAAE,OAAO,CAAA;IACtB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;CAC/B,CAkBA;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,WAAW,CAwEtD"}
@@ -1,5 +1,97 @@
1
+ import { spawnSync } from "child_process";
1
2
  import { existsSync, readdirSync } from "fs";
2
3
  import path from "path";
4
+ /**
5
+ * The Python the candidate's submission is actually GRADED on.
6
+ *
7
+ * Not a floor — an exact version, because we own both ends. The E2B grading
8
+ * image (`backend/assessment/template.py`) installs 3.12 from deadsnakes and
9
+ * points the candidate-facing `python`/`python3`/`pip` at it, and the workspace
10
+ * image is `litmus-base-python-3.12`. Anything a candidate writes has to run
11
+ * there, so that is the interpreter their local venv should be built on.
12
+ *
13
+ * Keep in sync with `GRADING_PYTHON_VERSION` in `backend/agent/system_prompt.py`
14
+ * and the deadsnakes pin in `backend/assessment/template.py` —
15
+ * `backend/tests/test_candidate_python_version_alignment.py` fails if they drift.
16
+ */
17
+ export const GRADING_PYTHON_VERSION = "3.12";
18
+ /**
19
+ * Interpreters to try, best first.
20
+ *
21
+ * The exact `GRADING_PYTHON_VERSION` comes first because the grader runs
22
+ * exactly that: a NEWER local interpreter is not "safe" the way a newer runtime
23
+ * usually is — 3.13 syntax parses locally and then fails in the sandbox.
24
+ *
25
+ * The rest are fallbacks, and their ORDER IS PLATFORM-SPECIFIC:
26
+ * - Windows: `py` (the launcher) then `python`. `python3` is deliberately
27
+ * last, because on Windows it is usually not a real interpreter at all —
28
+ * python.org's installer ships `python.exe` + `py.exe` and no `python3.exe`,
29
+ * and a stock Windows has an App Execution Alias at `python3.exe` that
30
+ * opens the Microsoft Store instead of running anything. Preferring it
31
+ * would hand a Windows candidate a command that cannot build a venv.
32
+ * - Everywhere else: `python3`. Bare `python` is not a fallback on Unix —
33
+ * where it exists at all it is as likely to be a Python 2 or a shim.
34
+ */
35
+ function pythonCandidates() {
36
+ const version = GRADING_PYTHON_VERSION;
37
+ if (process.platform === "win32") {
38
+ return [
39
+ { bin: "py", args: [`-${version}`], command: `py -${version}` },
40
+ { bin: "py", args: [], command: "py" },
41
+ { bin: "python", args: [], command: "python" },
42
+ { bin: "python3", args: [], command: "python3" },
43
+ ];
44
+ }
45
+ return [
46
+ { bin: `python${version}`, args: [], command: `python${version}` },
47
+ { bin: "python3", args: [], command: "python3" },
48
+ ];
49
+ }
50
+ /** `Python 3.12.4` on stdout (or stderr, on ancient builds) → `3.12.4`. */
51
+ function probeVersion(candidate) {
52
+ try {
53
+ const probed = spawnSync(candidate.bin, [...candidate.args, "--version"], {
54
+ encoding: "utf8",
55
+ shell: process.platform === "win32",
56
+ });
57
+ if (probed.status !== 0)
58
+ return null;
59
+ const match = /Python (\d+\.\d+(?:\.\d+)?)/.exec(`${probed.stdout ?? ""}${probed.stderr ?? ""}`);
60
+ return match ? match[1] : null;
61
+ }
62
+ catch {
63
+ // Treat an unspawnable probe exactly like a missing interpreter.
64
+ return null;
65
+ }
66
+ }
67
+ /**
68
+ * Pick the interpreter to build the assessment venv with.
69
+ *
70
+ * Never fatal: a candidate with no `GRADING_PYTHON_VERSION` still gets a venv
71
+ * and a warning. Blocking `litmus init` on a missing interpreter would be a far
72
+ * worse failure than the mismatch itself. If nothing probes clean we still
73
+ * return the platform's conventional name so the install is *attempted* — and
74
+ * `init` reports that attempt's outcome separately.
75
+ */
76
+ export function resolvePythonInterpreter() {
77
+ const candidates = pythonCandidates();
78
+ for (const candidate of candidates) {
79
+ const detectedVersion = probeVersion(candidate);
80
+ if (!detectedVersion)
81
+ continue;
82
+ return {
83
+ command: candidate.command,
84
+ // `py -3.12` can only ever be 3.12, but `python`/`python3` are whatever
85
+ // the machine has — so match on the PROBED version, never on which
86
+ // candidate we happened to be trying.
87
+ matchesGrader: detectedVersion.startsWith(`${GRADING_PYTHON_VERSION}.`) ||
88
+ detectedVersion === GRADING_PYTHON_VERSION,
89
+ detectedVersion,
90
+ };
91
+ }
92
+ const last = candidates[candidates.length - 1];
93
+ return { command: last.command, matchesGrader: false, detectedVersion: null };
94
+ }
3
95
  /**
4
96
  * Detect project type and suggest an install command.
5
97
  */
@@ -20,10 +112,26 @@ export function detectProject(dir) {
20
112
  // Create a venv and install into it (replaces what the old setup.sh did).
21
113
  // Use venv/bin/pip directly so activation isn't required at install time.
22
114
  const pipBin = process.platform === "win32" ? "venv\\Scripts\\pip" : "venv/bin/pip";
115
+ const python = resolvePythonInterpreter();
23
116
  const installCommand = existsSync(path.join(dir, "requirements.txt"))
24
- ? `python3 -m venv venv && ${pipBin} install -r requirements.txt`
25
- : `python3 -m venv venv && ${pipBin} install -e .`;
26
- return { type: "python", installCommand };
117
+ ? `${python.command} -m venv venv && ${pipBin} install -r requirements.txt`
118
+ : `${python.command} -m venv venv && ${pipBin} install -e .`;
119
+ // States the SKEW, not what happened: `init` runs this command and reports
120
+ // its outcome separately, so a warning that claimed the venv "was built"
121
+ // would contradict the failure message whenever the install did not run.
122
+ // The two cases read differently on purpose — "venv/ uses that interpreter"
123
+ // is nonsense when there is no interpreter to use.
124
+ const toolchainWarning = python.matchesGrader
125
+ ? undefined
126
+ : python.detectedVersion
127
+ ? `Your submission is graded on Python ${GRADING_PYTHON_VERSION}, but this machine has ` +
128
+ `Python ${python.detectedVersion} (\`${python.command}\`), which venv/ uses. Code that only ` +
129
+ `runs on a different version will fail when graded. Install Python ` +
130
+ `${GRADING_PYTHON_VERSION} and re-run \`litmus init\` to avoid the mismatch.`
131
+ : `No Python interpreter was found on your PATH, so \`${python.command} -m venv\` will ` +
132
+ `probably fail. Your submission is graded on Python ${GRADING_PYTHON_VERSION} — install ` +
133
+ `that version and re-run \`litmus init\`.`;
134
+ return { type: "python", installCommand, toolchainWarning };
27
135
  }
28
136
  if (existsSync(path.join(dir, "go.mod"))) {
29
137
  return { type: "go", installCommand: "go mod download" };
@@ -1 +1 @@
1
- {"version":3,"file":"detect-project.js","sourceRoot":"","sources":["../../src/lib/detect-project.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"detect-project.js","sourceRoot":"","sources":["../../src/lib/detect-project.ts"],"names":[],"mappings":"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"}
@@ -10,6 +10,25 @@ declare let errorContext: {
10
10
  };
11
11
  export declare function setErrorContext(ctx: Partial<typeof errorContext>): void;
12
12
  export type Severity = "error" | "warning" | "info";
13
+ /**
14
+ * POST a CLI event to the server for logging and optional email notification.
15
+ *
16
+ * severity:
17
+ * "error" → our fault (server errors, bugs) → email + log
18
+ * "warning" → candidate's fault (wrong dir, deadline, too large) → log only
19
+ * "info" → success events (init complete, submission received) → log only
20
+ *
21
+ * Never rejects — candidate experience is unaffected if this fails.
22
+ *
23
+ * Exported for `litmus doctor`'s diagnostic phone-home: doctor sends its
24
+ * forensic payload at severity "error" when it found or repaired problems,
25
+ * because "warning" and "info" are log-only server-side and a capture outage
26
+ * reported at warning severity is invisible to the team (that is exactly how
27
+ * a mid-session config deletion went unnoticed for hours). The server splits
28
+ * `message` on "\n\nInternal detail:" — keep the part before it stable so
29
+ * platform-health dedupe keeps working.
30
+ */
31
+ export declare function reportEvent(severity: Severity, message: string, hint?: string, event?: string): Promise<void>;
13
32
  /**
14
33
  * Fire-and-forget: log a success event (init complete, submission received).
15
34
  * Never blocks or affects the candidate.
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/lib/errors.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,YAAY,6BAA6B,CAAA;AAEtD,qBAAa,UAAW,SAAQ,KAAK;;CAEpC;AAID,QAAA,IAAI,YAAY,EAAE;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,cAAc,CAAC,EAAE,MAAM,CAAA;CACnB,CAAA;AAEN,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,CAAC,OAAO,YAAY,CAAC,GAAG,IAAI,CAEvE;AAED,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,CAAA;AAyCnD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAElE;AAED;;;;;;;;GAQG;AACH,wBAAgB,KAAK,CACnB,OAAO,EAAE,MAAM,EACf,IAAI,CAAC,EAAE,MAAM,EACb,IAAI,CAAC,EAAE,MAAM,GAAG;IAAE,cAAc,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,QAAQ,CAAA;CAAE,GAC/D,KAAK,CA2BP;AAED;;GAEG;AACH,wBAAgB,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE1C;AAED;;GAEG;AACH,wBAAgB,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE7C;AAED;;GAEG;AACH,wBAAgB,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE1C"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../src/lib/errors.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,YAAY,6BAA6B,CAAA;AAEtD,qBAAa,UAAW,SAAQ,KAAK;;CAEpC;AAID,QAAA,IAAI,YAAY,EAAE;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,cAAc,CAAC,EAAE,MAAM,CAAA;CACnB,CAAA;AAEN,wBAAgB,eAAe,CAAC,GAAG,EAAE,OAAO,CAAC,OAAO,YAAY,CAAC,GAAG,IAAI,CAEvE;AAED,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,CAAA;AAEnD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,WAAW,CACzB,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,MAAM,EACf,IAAI,CAAC,EAAE,MAAM,EACb,KAAK,CAAC,EAAE,MAAM,GACb,OAAO,CAAC,IAAI,CAAC,CAsBf;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,IAAI,CAElE;AAED;;;;;;;;GAQG;AACH,wBAAgB,KAAK,CACnB,OAAO,EAAE,MAAM,EACf,IAAI,CAAC,EAAE,MAAM,EACb,IAAI,CAAC,EAAE,MAAM,GAAG;IAAE,cAAc,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,QAAQ,CAAA;CAAE,GAC/D,KAAK,CA2BP;AAED;;GAEG;AACH,wBAAgB,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE1C;AAED;;GAEG;AACH,wBAAgB,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE7C;AAED;;GAEG;AACH,wBAAgB,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAE1C"}
@@ -25,8 +25,16 @@ export function setErrorContext(ctx) {
25
25
  * "info" → success events (init complete, submission received) → log only
26
26
  *
27
27
  * Never rejects — candidate experience is unaffected if this fails.
28
+ *
29
+ * Exported for `litmus doctor`'s diagnostic phone-home: doctor sends its
30
+ * forensic payload at severity "error" when it found or repaired problems,
31
+ * because "warning" and "info" are log-only server-side and a capture outage
32
+ * reported at warning severity is invisible to the team (that is exactly how
33
+ * a mid-session config deletion went unnoticed for hours). The server splits
34
+ * `message` on "\n\nInternal detail:" — keep the part before it stable so
35
+ * platform-health dedupe keeps working.
28
36
  */
29
- function reportEvent(severity, message, hint, event) {
37
+ export function reportEvent(severity, message, hint, event) {
30
38
  const apiBase = errorContext.apiBase || process.env.LITMUS_API_URL || DEFAULT_API_BASE;
31
39
  const url = `${apiBase}/api/cli/errors`;
32
40
  const payload = JSON.stringify({
@@ -1 +1 @@
1
- {"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/lib/errors.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/lib/errors.ts"],"names":[],"mappings":"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"}
@@ -1,3 +1,29 @@
1
+ /**
2
+ * True when a healthy watcher of ours is running for this project.
3
+ *
4
+ * Deliberately the SAME check startTracker uses to decide whether to respawn,
5
+ * so callers can't disagree with it. A bare `process.kill(pid, 0)` is not
6
+ * enough: macOS recycles PIDs from a small space, so a dead watcher's PID can
7
+ * belong to an unrelated live process — which would read as "running" while
8
+ * nothing is being recorded. The nonce cross-check is what rules that out.
9
+ */
10
+ export declare function isTrackerHealthy(projectDir: string): boolean;
11
+ /**
12
+ * Raw tracker liveness signals for `litmus doctor` — the boolean predicate
13
+ * plus the inputs it was computed from, so doctor can explain WHY a tracker
14
+ * reads as unhealthy and distinguish "no process" from "live pid that stopped
15
+ * heartbeating" (nonce older than the liveness window: a wedged watcher, a
16
+ * recycled pid over a dead one, or a machine that just woke from sleep).
17
+ */
18
+ export interface TrackerDiagnostics {
19
+ pid: number | null;
20
+ pidAlive: boolean;
21
+ /** ms since the watcher last proved liveness (nonce mtime), or null if no nonce. */
22
+ nonceAgeMs: number | null;
23
+ /** The same predicate startTracker/isTrackerHealthy use. */
24
+ healthy: boolean;
25
+ }
26
+ export declare function trackerDiagnostics(projectDir: string): TrackerDiagnostics;
1
27
  /**
2
28
  * Start the file watcher in the background.
3
29
  * Spawns watcher.js as a detached process; logs file events to
@@ -1 +1 @@
1
- {"version":3,"file":"tracker.d.ts","sourceRoot":"","sources":["../../src/lib/tracker.ts"],"names":[],"mappings":"AA+CA;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAwCrD"}
1
+ {"version":3,"file":"tracker.d.ts","sourceRoot":"","sources":["../../src/lib/tracker.ts"],"names":[],"mappings":"AA4FA;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAM5D;AAED;;;;;;GAMG;AACH,MAAM,WAAW,kBAAkB;IACjC,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;IAClB,QAAQ,EAAE,OAAO,CAAA;IACjB,oFAAoF;IACpF,UAAU,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,4DAA4D;IAC5D,OAAO,EAAE,OAAO,CAAA;CACjB;AAED,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,kBAAkB,CAsBzE;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAqErD"}
@@ -1,16 +1,29 @@
1
1
  import { spawn } from "child_process";
2
- import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync, statSync } from "fs";
2
+ import { existsSync, mkdirSync, openSync, readFileSync, writeFileSync, statSync, unlinkSync, closeSync } from "fs";
3
3
  import path from "path";
4
4
  import { fileURLToPath } from "url";
5
5
  const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = path.dirname(__filename);
7
7
  const NONCE_FILE = "tracker.nonce";
8
- // Max gap between watcher startup time (nonce mtime) and `now` to treat a
9
- // matching PID as legitimate rather than a recycled PID belonging to an
10
- // unrelated host process. Watcher writes the nonce immediately after spawn,
11
- // so even a freshly-started CLI session reads a recent nonce. If the gap is
12
- // larger than this, the PID is stale and we re-spawn.
13
- const NONCE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
8
+ // Max gap between the watcher's last heartbeat (nonce mtime) and `now` to treat
9
+ // a matching PID as legitimate rather than a recycled PID belonging to an
10
+ // unrelated host process. The watcher touches the nonce on startup and on every
11
+ // heartbeat, so a live watcher refreshes this every 5 minutes.
12
+ //
13
+ // THREE MISSED HEARTBEATS, not seven days. The old window only ruled out
14
+ // WEEK-OLD pid reuse: a watcher that died minutes ago whose pid was recycled by
15
+ // an unrelated live process still read as healthy, so `litmus status` skipped
16
+ // the revival and the rest of the session went unrecorded — the exact failure
17
+ // ENG-1215 is about. 3 missed beats matches the house convention for the same
18
+ // question elsewhere (HOST_CORDON_AFTER_SECONDS = 180 = 3 x 60s).
19
+ //
20
+ // Tightening this was previously unsafe and is not any more. The risk was the
21
+ // other direction: a laptop that sleeps for an hour leaves a live watcher with a
22
+ // stale nonce, so we would respawn and end up with TWO watchers interleaving
23
+ // _seq/_prevHash chains on one activity.jsonl. watcher.ts now re-checks the pid
24
+ // claim every second, so the loser exits within ~1s of the spawn. A spurious
25
+ // respawn is self-correcting; a missed revival is not.
26
+ const NONCE_MAX_AGE_MS = 15 * 60 * 1000; // 15 min = 3 missed 5-minute heartbeats
14
27
  function isProcessAlive(pid) {
15
28
  try {
16
29
  process.kill(pid, 0); // Signal 0 = check if process exists
@@ -45,6 +58,74 @@ function isExistingTrackerOurs(pidFile, noncePath) {
45
58
  return false;
46
59
  }
47
60
  }
61
+ const SPAWN_LOCK_FILE = "tracker.spawn.lock";
62
+ // A spawn is a few milliseconds of syscalls. Anything older than this was
63
+ // leaked by a process that died mid-spawn, not a live contender — reclaim it
64
+ // rather than blocking tracking for the rest of the session.
65
+ const SPAWN_LOCK_STALE_MS = 30000;
66
+ /**
67
+ * Exclusive, cross-platform spawn lock. `wx` fails if the file exists, and that
68
+ * create-or-fail is atomic, so exactly one caller wins.
69
+ *
70
+ * Returns the fd on success, or null when another process holds a fresh lock.
71
+ */
72
+ function acquireSpawnLock(lockPath) {
73
+ for (let attempt = 0; attempt < 2; attempt++) {
74
+ try {
75
+ return openSync(lockPath, "wx");
76
+ }
77
+ catch {
78
+ // Held. Only reclaim it if it's older than any real spawn could take.
79
+ try {
80
+ if (Date.now() - statSync(lockPath).mtimeMs > SPAWN_LOCK_STALE_MS) {
81
+ unlinkSync(lockPath);
82
+ continue; // retry once against the now-free path
83
+ }
84
+ }
85
+ catch {
86
+ continue; // vanished between open and stat — the holder just released
87
+ }
88
+ return null;
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+ /**
94
+ * True when a healthy watcher of ours is running for this project.
95
+ *
96
+ * Deliberately the SAME check startTracker uses to decide whether to respawn,
97
+ * so callers can't disagree with it. A bare `process.kill(pid, 0)` is not
98
+ * enough: macOS recycles PIDs from a small space, so a dead watcher's PID can
99
+ * belong to an unrelated live process — which would read as "running" while
100
+ * nothing is being recorded. The nonce cross-check is what rules that out.
101
+ */
102
+ export function isTrackerHealthy(projectDir) {
103
+ const litmusDir = path.join(projectDir, ".litmus");
104
+ return isExistingTrackerOurs(path.join(litmusDir, "tracker.pid"), path.join(litmusDir, NONCE_FILE));
105
+ }
106
+ export function trackerDiagnostics(projectDir) {
107
+ const litmusDir = path.join(projectDir, ".litmus");
108
+ const pidFile = path.join(litmusDir, "tracker.pid");
109
+ const noncePath = path.join(litmusDir, NONCE_FILE);
110
+ let pid = null;
111
+ try {
112
+ const parsed = parseInt(readFileSync(pidFile, "utf8").trim(), 10);
113
+ if (!isNaN(parsed))
114
+ pid = parsed;
115
+ }
116
+ catch { /* no pid file */ }
117
+ let nonceAgeMs = null;
118
+ try {
119
+ nonceAgeMs = Date.now() - statSync(noncePath).mtimeMs;
120
+ }
121
+ catch { /* no nonce */ }
122
+ return {
123
+ pid,
124
+ pidAlive: pid !== null && isProcessAlive(pid),
125
+ nonceAgeMs,
126
+ healthy: isExistingTrackerOurs(pidFile, noncePath),
127
+ };
128
+ }
48
129
  /**
49
130
  * Start the file watcher in the background.
50
131
  * Spawns watcher.js as a detached process; logs file events to
@@ -71,22 +152,58 @@ export function startTracker(projectDir) {
71
152
  // is our watcher and silently disabling tracking.
72
153
  if (isExistingTrackerOurs(pidFile, noncePath))
73
154
  return;
74
- const errFd = openSync(errLog, "a");
75
- const watcherPath = path.join(__dirname, "watcher.js");
76
- const cliBinPath = path.join(__dirname, "..", "index.js");
77
- // Pass the nonce path so the watcher can touch it on startup + heartbeat.
78
- const child = spawn(process.execPath, [watcherPath, projectDir, activityLog, cliBinPath, noncePath], {
79
- detached: true,
80
- stdio: ["ignore", "ignore", errFd],
81
- });
82
- if (child.pid) {
83
- child.unref();
155
+ // Serialize spawns across processes. Without this, two callers racing (e.g.
156
+ // two `litmus status` runs against a dead watcher) can both pass the health
157
+ // check above and each spawn a watcher; the second pid write then hides the
158
+ // first, leaving two live watchers appending to the same activity.jsonl with
159
+ // independent _seq/_prevHash chains. That corrupts the tamper-evidence chain
160
+ // (ENG-952), double-counts events, and arms two deadline auto-submits.
161
+ const lockPath = path.join(litmusDir, SPAWN_LOCK_FILE);
162
+ const lockFd = acquireSpawnLock(lockPath);
163
+ if (lockFd === null)
164
+ return; // another process is mid-spawn; let it win
165
+ try {
166
+ // Re-check under the lock: the winner of a race may have finished spawning
167
+ // between our check above and our acquiring the lock.
168
+ if (isExistingTrackerOurs(pidFile, noncePath))
169
+ return;
170
+ const errFd = openSync(errLog, "a");
171
+ const watcherPath = path.join(__dirname, "watcher.js");
172
+ const cliBinPath = path.join(__dirname, "..", "index.js");
173
+ // Pass the nonce path so the watcher can touch it on startup + heartbeat.
174
+ const child = spawn(process.execPath, [watcherPath, projectDir, activityLog, cliBinPath, noncePath], {
175
+ detached: true,
176
+ stdio: ["ignore", "ignore", errFd],
177
+ });
178
+ if (child.pid) {
179
+ child.unref();
180
+ try {
181
+ writeFileSync(pidFile, String(child.pid), "utf8");
182
+ // Stamp the nonce HERE, in the parent, rather than leaving it to the
183
+ // watcher's own touchNonce() on boot. isExistingTrackerOurs requires
184
+ // pid-alive AND a nonce, and the watcher needs ~100ms of Node startup
185
+ // before it writes one — so between spawn and that first write, a
186
+ // freshly-spawned watcher reads as UNHEALTHY. Concurrent callers then
187
+ // each spawn their own (observed: 6 watchers from 8 racing calls, even
188
+ // with the spawn lock, because each re-check under the lock saw a
189
+ // nonce-less pid). The watcher refreshes this on startup and on every
190
+ // heartbeat; the parent write just closes the boot-time gap.
191
+ writeFileSync(noncePath, String(Date.now()), "utf8");
192
+ }
193
+ catch {
194
+ // Non-critical
195
+ }
196
+ }
197
+ }
198
+ finally {
84
199
  try {
85
- writeFileSync(pidFile, String(child.pid), "utf8");
200
+ closeSync(lockFd);
86
201
  }
87
- catch {
88
- // Non-critical
202
+ catch { /* already closed */ }
203
+ try {
204
+ unlinkSync(lockPath);
89
205
  }
206
+ catch { /* already reclaimed */ }
90
207
  }
91
208
  }
92
209
  //# sourceMappingURL=tracker.js.map