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.
- package/dist/commands/doctor.d.ts +136 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +1055 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +15 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +24 -10
- package/dist/commands/status.js.map +1 -1
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/ai-tracking.d.ts +35 -0
- package/dist/lib/ai-tracking.d.ts.map +1 -1
- package/dist/lib/ai-tracking.js +40 -10
- package/dist/lib/ai-tracking.js.map +1 -1
- package/dist/lib/api.d.ts +14 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +40 -0
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/backfill.d.ts +106 -0
- package/dist/lib/backfill.d.ts.map +1 -0
- package/dist/lib/backfill.js +356 -0
- package/dist/lib/backfill.js.map +1 -0
- package/dist/lib/chain.d.ts +42 -0
- package/dist/lib/chain.d.ts.map +1 -0
- package/dist/lib/chain.js +98 -0
- package/dist/lib/chain.js.map +1 -0
- package/dist/lib/detect-project.d.ts +35 -0
- package/dist/lib/detect-project.d.ts.map +1 -1
- package/dist/lib/detect-project.js +111 -3
- package/dist/lib/detect-project.js.map +1 -1
- package/dist/lib/errors.d.ts +19 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +9 -1
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/tracker.d.ts +26 -0
- package/dist/lib/tracker.d.ts.map +1 -1
- package/dist/lib/tracker.js +137 -20
- package/dist/lib/tracker.js.map +1 -1
- package/dist/lib/watcher.js +270 -5
- package/dist/lib/watcher.js.map +1 -1
- package/dist/lib/zip.js +2 -2
- package/dist/lib/zip.js.map +1 -1
- 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":"
|
|
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
|
-
?
|
|
25
|
-
:
|
|
26
|
-
|
|
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":"AAAA,OAAO,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,IAAI,CAAA;AAC5C,OAAO,IAAI,MAAM,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"detect-project.js","sourceRoot":"","sources":["../../src/lib/detect-project.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/errors.d.ts
CHANGED
|
@@ -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.
|
package/dist/lib/errors.d.ts.map
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/lib/errors.js
CHANGED
|
@@ -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({
|
package/dist/lib/errors.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/lib/errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AACzB,OAAO,EAAE,MAAM,IAAI,CAAA;AAEnB,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAA;AAE1C,wEAAwE;AACxE,0EAA0E;AAC1E,mEAAmE;AACnE,mEAAmE;AACnE,+DAA+D;AAC/D,MAAM,CAAC,MAAM,YAAY,GAAG,0BAA0B,CAAA;AAEtD,MAAM,OAAO,UAAW,SAAQ,KAAK;IACnC,gBAAgB,KAAK,EAAE,CAAA,CAAC,CAAC;CAC1B;AACD,MAAM,gBAAgB,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,YAAY,CAAA;AAEnE,wEAAwE;AACxE,IAAI,YAAY,GAKZ,EAAE,CAAA;AAEN,MAAM,UAAU,eAAe,CAAC,GAAiC;IAC/D,YAAY,GAAG,EAAE,GAAG,YAAY,EAAE,GAAG,GAAG,EAAE,CAAA;AAC5C,CAAC;AAID
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/lib/errors.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/tracker.d.ts
CHANGED
|
@@ -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":"
|
|
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"}
|
package/dist/lib/tracker.js
CHANGED
|
@@ -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
|
|
9
|
-
// matching PID as legitimate rather than a recycled PID belonging to an
|
|
10
|
-
// unrelated host process.
|
|
11
|
-
// so
|
|
12
|
-
//
|
|
13
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
|
|
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
|
-
|
|
200
|
+
closeSync(lockFd);
|
|
86
201
|
}
|
|
87
|
-
catch {
|
|
88
|
-
|
|
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
|